From: Jeff Veit Date: Wed, 27 Oct 2021 14:15:05 +0000 (+0100) Subject: Pull merge. X-Git-Url: http://www.aleph1.co.uk/gitweb/?p=yaffs-website;a=commitdiff_plain;h=refs%2Fheads%2Fd864;hp=4f1b9b4ab48a8498afac9e2213a02a23ccf4a06c Pull merge. --- diff --git a/composer.json b/composer.json index bea1fd9b4..f8d00ee3e 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,7 @@ "composer/installers": "^1.2", "drupal-composer/drupal-scaffold": "^2.2", "cweagans/composer-patches": "~1.0", - "drupal/core": "^8.5.0", + "drupal/core": "8.6.4", "drupal/console": "^1.0", "drupal/token": "^1.0", "drupal/ctools": "^3.0", diff --git a/composer.lock b/composer.lock index 7c7370821..62ee26594 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8cfc3879a25f15d6aba3eaccfc3cb818", + "content-hash": "38bc6fe27fbd03929a4718f3c529c7d0", "packages": [ { "name": "alchemy/zippy", @@ -2483,16 +2483,16 @@ }, { "name": "drupal/core", - "version": "8.6.3", + "version": "8.6.4", "source": { "type": "git", "url": "https://github.com/drupal/core.git", - "reference": "9e9a1dd9e280ebaf10622217e54448b529167965" + "reference": "652bdf56b5e9e84648ea53ac7b1e7e73e7608ef4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/drupal/core/zipball/9e9a1dd9e280ebaf10622217e54448b529167965", - "reference": "9e9a1dd9e280ebaf10622217e54448b529167965", + "url": "https://api.github.com/repos/drupal/core/zipball/652bdf56b5e9e84648ea53ac7b1e7e73e7608ef4", + "reference": "652bdf56b5e9e84648ea53ac7b1e7e73e7608ef4", "shasum": "" }, "require": { @@ -2717,7 +2717,7 @@ "GPL-2.0-or-later" ], "description": "Drupal is an open source content management platform powering millions of websites and applications.", - "time": "2018-11-07T14:45:40+00:00" + "time": "2018-12-05T11:58:02+00:00" }, { "name": "drupal/crop", diff --git a/vendor/composer/installed.json b/vendor/composer/installed.json index c65055a1c..f1047d580 100644 --- a/vendor/composer/installed.json +++ b/vendor/composer/installed.json @@ -2849,17 +2849,17 @@ }, { "name": "drupal/core", - "version": "8.6.3", - "version_normalized": "8.6.3.0", + "version": "8.6.4", + "version_normalized": "8.6.4.0", "source": { "type": "git", "url": "https://github.com/drupal/core.git", - "reference": "9e9a1dd9e280ebaf10622217e54448b529167965" + "reference": "652bdf56b5e9e84648ea53ac7b1e7e73e7608ef4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/drupal/core/zipball/9e9a1dd9e280ebaf10622217e54448b529167965", - "reference": "9e9a1dd9e280ebaf10622217e54448b529167965", + "url": "https://api.github.com/repos/drupal/core/zipball/652bdf56b5e9e84648ea53ac7b1e7e73e7608ef4", + "reference": "652bdf56b5e9e84648ea53ac7b1e7e73e7608ef4", "shasum": "" }, "require": { @@ -3031,7 +3031,7 @@ "symfony/debug": "^3.4.0", "symfony/phpunit-bridge": "^3.4.3" }, - "time": "2018-11-07T14:45:40+00:00", + "time": "2018-12-05T11:58:02+00:00", "type": "drupal-core", "extra": { "merge-plugin": { diff --git a/web/.ht.router.php b/web/.ht.router.php index 3da80a17f..054f7119b 100644 --- a/web/.ht.router.php +++ b/web/.ht.router.php @@ -25,7 +25,7 @@ */ $url = parse_url($_SERVER['REQUEST_URI']); -if (file_exists('.' . $url['path'])) { +if (file_exists(__DIR__ . $url['path'])) { // Serve the requested resource as-is. return FALSE; } @@ -38,7 +38,7 @@ if (strpos($path, '.php') !== FALSE) { // fallback to index.php. do { $path = dirname($path); - if (preg_match('/\.php$/', $path) && is_file('.' . $path)) { + if (preg_match('/\.php$/', $path) && is_file(__DIR__ . $path)) { // Discovered that the path contains an existing PHP file. Use that as the // script to include. $script = ltrim($path, '/'); diff --git a/web/core/MAINTAINERS.txt b/web/core/MAINTAINERS.txt index f56097cf3..e29eb69e4 100644 --- a/web/core/MAINTAINERS.txt +++ b/web/core/MAINTAINERS.txt @@ -284,6 +284,7 @@ Menu UI Migrate - Adam Globus-Hoenich 'phenaproxima' https://www.drupal.org/u/phenaproxima - Lucas Hedding 'heddn' https://www.drupal.org/u/heddn +- Michael Lutz 'mikelutz' https://www.drupal.org/u/mikelutz - Markus Sipilä 'masipila' https://www.drupal.org/u/masipila - Vicki Spagnolo 'quietone' https://www.drupal.org/u/quietone - Maxime Turcotte 'maxocub' https://www.drupal.org/u/maxocub diff --git a/web/core/includes/common.inc b/web/core/includes/common.inc index 49cf6e104..5090c45fa 100644 --- a/web/core/includes/common.inc +++ b/web/core/includes/common.inc @@ -251,42 +251,46 @@ function check_url($uri) { * A translated string representation of the size. */ function format_size($size, $langcode = NULL) { - if ($size < Bytes::KILOBYTE) { + $absolute_size = abs($size); + if ($absolute_size < Bytes::KILOBYTE) { return \Drupal::translation()->formatPlural($size, '1 byte', '@count bytes', [], ['langcode' => $langcode]); } - else { - // Convert bytes to kilobytes. - $size = $size / Bytes::KILOBYTE; - $units = ['KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; - foreach ($units as $unit) { - if (round($size, 2) >= Bytes::KILOBYTE) { - $size = $size / Bytes::KILOBYTE; - } - else { - break; - } - } - $args = ['@size' => round($size, 2)]; - $options = ['langcode' => $langcode]; - switch ($unit) { - case 'KB': - return new TranslatableMarkup('@size KB', $args, $options); - case 'MB': - return new TranslatableMarkup('@size MB', $args, $options); - case 'GB': - return new TranslatableMarkup('@size GB', $args, $options); - case 'TB': - return new TranslatableMarkup('@size TB', $args, $options); - case 'PB': - return new TranslatableMarkup('@size PB', $args, $options); - case 'EB': - return new TranslatableMarkup('@size EB', $args, $options); - case 'ZB': - return new TranslatableMarkup('@size ZB', $args, $options); - case 'YB': - return new TranslatableMarkup('@size YB', $args, $options); + // Create a multiplier to preserve the sign of $size. + $sign = $absolute_size / $size; + foreach (['KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] as $unit) { + $absolute_size /= Bytes::KILOBYTE; + $rounded_size = round($absolute_size, 2); + if ($rounded_size < Bytes::KILOBYTE) { + break; } } + $args = ['@size' => $rounded_size * $sign]; + $options = ['langcode' => $langcode]; + switch ($unit) { + case 'KB': + return new TranslatableMarkup('@size KB', $args, $options); + + case 'MB': + return new TranslatableMarkup('@size MB', $args, $options); + + case 'GB': + return new TranslatableMarkup('@size GB', $args, $options); + + case 'TB': + return new TranslatableMarkup('@size TB', $args, $options); + + case 'PB': + return new TranslatableMarkup('@size PB', $args, $options); + + case 'EB': + return new TranslatableMarkup('@size EB', $args, $options); + + case 'ZB': + return new TranslatableMarkup('@size ZB', $args, $options); + + case 'YB': + return new TranslatableMarkup('@size YB', $args, $options); + } } /** @@ -490,7 +494,7 @@ function drupal_js_defaults($data = NULL) { * * Every condition is a key/value pair, whose key is a jQuery selector that * denotes another element on the page, and whose value is an array of - * conditions, which must bet met on that element: + * conditions, which must be met on that element: * @code * array( * 'visible' => array( diff --git a/web/core/lib/Drupal.php b/web/core/lib/Drupal.php index ebd52afba..014aea27f 100644 --- a/web/core/lib/Drupal.php +++ b/web/core/lib/Drupal.php @@ -82,7 +82,7 @@ class Drupal { /** * The current system version. */ - const VERSION = '8.6.3'; + const VERSION = '8.6.4'; /** * Core API compatibility. diff --git a/web/core/lib/Drupal/Component/Plugin/ContextAwarePluginBase.php b/web/core/lib/Drupal/Component/Plugin/ContextAwarePluginBase.php index b9f6e0221..2d499fd7d 100644 --- a/web/core/lib/Drupal/Component/Plugin/ContextAwarePluginBase.php +++ b/web/core/lib/Drupal/Component/Plugin/ContextAwarePluginBase.php @@ -3,6 +3,7 @@ namespace Drupal\Component\Plugin; use Drupal\Component\Plugin\Context\ContextInterface; +use Drupal\Component\Plugin\Definition\ContextAwarePluginDefinitionInterface; use Drupal\Component\Plugin\Exception\ContextException; use Drupal\Component\Plugin\Context\Context; use Symfony\Component\Validator\ConstraintViolationList; @@ -67,7 +68,12 @@ abstract class ContextAwarePluginBase extends PluginBase implements ContextAware */ public function getContextDefinitions() { $definition = $this->getPluginDefinition(); - return !empty($definition['context']) ? $definition['context'] : []; + if ($definition instanceof ContextAwarePluginDefinitionInterface) { + return $definition->getContextDefinitions(); + } + else { + return !empty($definition['context']) ? $definition['context'] : []; + } } /** @@ -75,10 +81,15 @@ abstract class ContextAwarePluginBase extends PluginBase implements ContextAware */ public function getContextDefinition($name) { $definition = $this->getPluginDefinition(); - if (empty($definition['context'][$name])) { - throw new ContextException(sprintf("The %s context is not a valid context.", $name)); + if ($definition instanceof ContextAwarePluginDefinitionInterface) { + if ($definition->hasContextDefinition($name)) { + return $definition->getContextDefinition($name); + } + } + elseif (!empty($definition['context'][$name])) { + return $definition['context'][$name]; } - return $definition['context'][$name]; + throw new ContextException(sprintf("The %s context is not a valid context.", $name)); } /** diff --git a/web/core/lib/Drupal/Component/Utility/Mail.php b/web/core/lib/Drupal/Component/Utility/Mail.php new file mode 100644 index 000000000..423cfb266 --- /dev/null +++ b/web/core/lib/Drupal/Component/Utility/Mail.php @@ -0,0 +1,67 @@ +[]:;@\,."'; + + /** + * Return a RFC-2822 compliant "display-name" component. + * + * The "display-name" component is used in mail header "Originator" fields + * (From, Sender, Reply-to) to give a human-friendly description of the + * address, i.e. From: My Display Name . RFC-822 and + * RFC-2822 define its syntax and rules. This method gets as input a string + * to be used as "display-name" and formats it to be RFC compliant. + * + * @param string $string + * A string to be used as "display-name". + * + * @return string + * A RFC compliant version of the string, ready to be used as + * "display-name" in mail originator header fields. + */ + public static function formatDisplayName($string) { + // Make sure we don't process html-encoded characters. They may create + // unneeded trouble if left encoded, besides they will be correctly + // processed if decoded. + $string = Html::decodeEntities($string); + + // If string contains non-ASCII characters it must be (short) encoded + // according to RFC-2047. The output of a "B" (Base64) encoded-word is + // always safe to be used as display-name. + $safe_display_name = Unicode::mimeHeaderEncode($string, TRUE); + + // Encoded-words are always safe to be used as display-name because don't + // contain any RFC 2822 "specials" characters. However + // Unicode::mimeHeaderEncode() encodes a string only if it contains any + // non-ASCII characters, and leaves its value untouched (un-encoded) if + // ASCII only. For this reason in order to produce a valid display-name we + // still need to make sure there are no "specials" characters left. + if (preg_match('/[' . preg_quote(Mail::RFC_2822_SPECIALS) . ']/', $safe_display_name)) { + + // If string is already quoted, it may or may not be escaped properly, so + // don't trust it and reset. + if (preg_match('/^"(.+)"$/', $safe_display_name, $matches)) { + $safe_display_name = str_replace(['\\\\', '\\"'], ['\\', '"'], $matches[1]); + } + + // Transform the string in a RFC-2822 "quoted-string" by wrapping it in + // double-quotes. Also make sure '"' and '\' occurrences are escaped. + $safe_display_name = '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $safe_display_name) . '"'; + + } + + return $safe_display_name; + } + +} diff --git a/web/core/lib/Drupal/Core/Block/BlockBase.php b/web/core/lib/Drupal/Core/Block/BlockBase.php index a5f7fa9ed..bebdf09bd 100644 --- a/web/core/lib/Drupal/Core/Block/BlockBase.php +++ b/web/core/lib/Drupal/Core/Block/BlockBase.php @@ -11,6 +11,7 @@ use Drupal\Component\Utility\NestedArray; use Drupal\Core\Language\LanguageInterface; use Drupal\Core\Plugin\PluginWithFormsInterface; use Drupal\Core\Plugin\PluginWithFormsTrait; +use Drupal\Core\Render\PreviewFallbackInterface; use Drupal\Core\Session\AccountInterface; use Drupal\Component\Transliteration\TransliterationInterface; @@ -23,7 +24,7 @@ use Drupal\Component\Transliteration\TransliterationInterface; * * @ingroup block_api */ -abstract class BlockBase extends ContextAwarePluginBase implements BlockPluginInterface, PluginWithFormsInterface { +abstract class BlockBase extends ContextAwarePluginBase implements BlockPluginInterface, PluginWithFormsInterface, PreviewFallbackInterface { use ContextAwarePluginAssignmentTrait; use MessengerTrait; @@ -252,6 +253,13 @@ abstract class BlockBase extends ContextAwarePluginBase implements BlockPluginIn return $transliterated; } + /** + * {@inheritdoc} + */ + public function getPreviewFallbackString() { + return $this->t('Placeholder for the "@block" block', ['@block' => $this->label()]); + } + /** * Wraps the transliteration service. * diff --git a/web/core/lib/Drupal/Core/Database/Install/Tasks.php b/web/core/lib/Drupal/Core/Database/Install/Tasks.php index 44bddea81..90a077200 100644 --- a/web/core/lib/Drupal/Core/Database/Install/Tasks.php +++ b/web/core/lib/Drupal/Core/Database/Install/Tasks.php @@ -33,7 +33,7 @@ abstract class Tasks { ], [ 'arguments' => [ - 'CREATE TABLE {drupal_install_test} (id int NULL)', + 'CREATE TABLE {drupal_install_test} (id int NOT NULL PRIMARY KEY)', 'Drupal can use CREATE TABLE database commands.', 'Failed to CREATE a test table on your database server with the command %query. The server reports the following message: %error.

Are you sure the configured username has the necessary permissions to create tables in the database?

', TRUE, diff --git a/web/core/lib/Drupal/Core/Entity/Entity/EntityViewDisplay.php b/web/core/lib/Drupal/Core/Entity/Entity/EntityViewDisplay.php index cd36418d1..6783522ac 100644 --- a/web/core/lib/Drupal/Core/Entity/Entity/EntityViewDisplay.php +++ b/web/core/lib/Drupal/Core/Entity/Entity/EntityViewDisplay.php @@ -8,6 +8,7 @@ use Drupal\Core\Entity\EntityDisplayPluginCollection; use Drupal\Core\Entity\EntityStorageInterface; use Drupal\Core\Entity\FieldableEntityInterface; use Drupal\Core\Entity\EntityDisplayBase; +use Drupal\Core\Render\Element; use Drupal\Core\TypedData\TranslatableInterface as TranslatableDataInterface; /** @@ -269,7 +270,7 @@ class EntityViewDisplay extends EntityDisplayBase implements EntityViewDisplayIn foreach ($entities as $id => $entity) { // Assign the configured weights. foreach ($this->getComponents() as $name => $options) { - if (isset($build_list[$id][$name])) { + if (isset($build_list[$id][$name]) && !Element::isEmpty($build_list[$id][$name])) { $build_list[$id][$name]['#weight'] = $options['weight']; } } diff --git a/web/core/lib/Drupal/Core/Entity/Plugin/DataType/ConfigEntityAdapter.php b/web/core/lib/Drupal/Core/Entity/Plugin/DataType/ConfigEntityAdapter.php index 093c77dd1..55a8bba0b 100644 --- a/web/core/lib/Drupal/Core/Entity/Plugin/DataType/ConfigEntityAdapter.php +++ b/web/core/lib/Drupal/Core/Entity/Plugin/DataType/ConfigEntityAdapter.php @@ -2,7 +2,9 @@ namespace Drupal\Core\Entity\Plugin\DataType; +use Drupal\Core\Config\TypedConfigManagerInterface; use Drupal\Core\TypedData\Exception\MissingDataException; +use Drupal\Core\TypedData\TypedDataManagerInterface; /** * Enhances EntityAdapter for config entities. @@ -16,6 +18,13 @@ class ConfigEntityAdapter extends EntityAdapter { */ protected $entity; + /** + * The typed config manager. + * + * @var \Drupal\Core\Config\TypedConfigManagerInterface + */ + protected $typedConfigManager; + /** * {@inheritdoc} */ @@ -68,10 +77,31 @@ class ConfigEntityAdapter extends EntityAdapter { } /** - * Gets the typed data manager. + * Gets the typed config manager. * * @return \Drupal\Core\Config\TypedConfigManagerInterface - * The typed data manager. + * The typed config manager. + */ + protected function getTypedConfigManager() { + if (empty($this->typedConfigManager)) { + // Use the typed data manager if it is also the typed config manager. + // @todo Remove this in https://www.drupal.org/node/3011137. + $typed_data_manager = $this->getTypedDataManager(); + if ($typed_data_manager instanceof TypedConfigManagerInterface) { + $this->typedConfigManager = $typed_data_manager; + } + else { + $this->typedConfigManager = \Drupal::service('config.typed'); + } + } + + return $this->typedConfigManager; + } + + /** + * {@inheritdoc} + * + * @todo Remove this in https://www.drupal.org/node/3011137. */ public function getTypedDataManager() { if (empty($this->typedDataManager)) { @@ -81,6 +111,19 @@ class ConfigEntityAdapter extends EntityAdapter { return $this->typedDataManager; } + /** + * {@inheritdoc} + * + * @todo Remove this in https://www.drupal.org/node/3011137. + */ + public function setTypedDataManager(TypedDataManagerInterface $typed_data_manager) { + $this->typedDataManager = $typed_data_manager; + if ($typed_data_manager instanceof TypedConfigManagerInterface) { + $this->typedConfigManager = $typed_data_manager; + } + return $this; + } + /** * {@inheritdoc} */ @@ -97,7 +140,7 @@ class ConfigEntityAdapter extends EntityAdapter { * The typed data. */ protected function getConfigTypedData() { - return $this->getTypedDataManager()->createFromNameAndData($this->entity->getConfigDependencyName(), $this->entity->toArray()); + return $this->getTypedConfigManager()->createFromNameAndData($this->entity->getConfigDependencyName(), $this->entity->toArray()); } } diff --git a/web/core/lib/Drupal/Core/Entity/Query/Sql/Tables.php b/web/core/lib/Drupal/Core/Entity/Query/Sql/Tables.php index a9ff4b136..1f99d0f77 100644 --- a/web/core/lib/Drupal/Core/Entity/Query/Sql/Tables.php +++ b/web/core/lib/Drupal/Core/Entity/Query/Sql/Tables.php @@ -184,6 +184,7 @@ class Tables implements TablesInterface { // finds the property first. The data table is preferred, which is why // it gets added before the base table. $entity_tables = []; + $revision_table = NULL; if ($all_revisions && $field_storage && $field_storage->isRevisionable()) { $data_table = $entity_type->getRevisionDataTable(); $entity_base_table = $entity_type->getRevisionTable(); @@ -191,11 +192,18 @@ class Tables implements TablesInterface { else { $data_table = $entity_type->getDataTable(); $entity_base_table = $entity_type->getBaseTable(); + + if ($field_storage && $field_storage->isRevisionable() && in_array($field_storage->getName(), $entity_type->getRevisionMetadataKeys())) { + $revision_table = $entity_type->getRevisionTable(); + } } if ($data_table) { $this->sqlQuery->addMetaData('simple_query', FALSE); $entity_tables[$data_table] = $this->getTableMapping($data_table, $entity_type_id); } + if ($revision_table) { + $entity_tables[$revision_table] = $this->getTableMapping($revision_table, $entity_type_id); + } $entity_tables[$entity_base_table] = $this->getTableMapping($entity_base_table, $entity_type_id); $sql_column = $specifier; diff --git a/web/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorage.php b/web/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorage.php index 3fdf81b74..48545d22d 100644 --- a/web/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorage.php +++ b/web/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorage.php @@ -821,10 +821,13 @@ class SqlContentEntityStorage extends ContentEntityStorageBase implements SqlEnt if ($update) { $default_revision = $entity->isDefaultRevision(); if ($default_revision) { + // Remove the ID from the record to enable updates on SQL variants + // that prevent updating serial columns, for example, mssql. + unset($record->{$this->idKey}); $this->database ->update($this->baseTable) ->fields((array) $record) - ->condition($this->idKey, $record->{$this->idKey}) + ->condition($this->idKey, $entity->get($this->idKey)->value) ->execute(); } if ($this->revisionTable) { @@ -833,11 +836,15 @@ class SqlContentEntityStorage extends ContentEntityStorageBase implements SqlEnt } else { $record = $this->mapToStorageRecord($entity->getUntranslated(), $this->revisionTable); + // Remove the revision ID from the record to enable updates on SQL + // variants that prevent updating serial columns, for example, + // mssql. + unset($record->{$this->revisionKey}); $entity->preSaveRevision($this, $record); $this->database ->update($this->revisionTable) ->fields((array) $record) - ->condition($this->revisionKey, $record->{$this->revisionKey}) + ->condition($this->revisionKey, $entity->getRevisionId()) ->execute(); } } @@ -1064,19 +1071,21 @@ class SqlContentEntityStorage extends ContentEntityStorageBase implements SqlEnt ->condition($this->idKey, $record->{$this->idKey}) ->execute(); } + // Make sure to update the new revision key for the entity. + $entity->{$this->revisionKey}->value = $record->{$this->revisionKey}; } else { + // Remove the revision ID from the record to enable updates on SQL + // variants that prevent updating serial columns, for example, + // mssql. + unset($record->{$this->revisionKey}); $this->database ->update($this->revisionTable) ->fields((array) $record) - ->condition($this->revisionKey, $record->{$this->revisionKey}) + ->condition($this->revisionKey, $entity->getRevisionId()) ->execute(); } - - // Make sure to update the new revision key for the entity. - $entity->{$this->revisionKey}->value = $record->{$this->revisionKey}; - - return $record->{$this->revisionKey}; + return $entity->getRevisionId(); } /** diff --git a/web/core/lib/Drupal/Core/Entity/entity.api.php b/web/core/lib/Drupal/Core/Entity/entity.api.php index ac137330f..65b913d12 100644 --- a/web/core/lib/Drupal/Core/Entity/entity.api.php +++ b/web/core/lib/Drupal/Core/Entity/entity.api.php @@ -811,10 +811,10 @@ function hook_entity_view_mode_info_alter(&$view_modes) { * An associative array of all entity bundles, keyed by the entity * type name, and then the bundle name, with the following keys: * - label: The human-readable name of the bundle. - * - uri_callback: The same as the 'uri_callback' key defined for the entity - * type in the EntityManager, but for the bundle only. When determining - * the URI of an entity, if a 'uri_callback' is defined for both the - * entity type and the bundle, the one for the bundle is used. + * - uri_callback: (optional) The same as the 'uri_callback' key defined for + * the entity type in the EntityManager, but for the bundle only. When + * determining the URI of an entity, if a 'uri_callback' is defined for both + * the entity type and the bundle, the one for the bundle is used. * - translatable: (optional) A boolean value specifying whether this bundle * has translation support enabled. Defaults to FALSE. * diff --git a/web/core/lib/Drupal/Core/Field/Plugin/Field/FieldFormatter/StringFormatter.php b/web/core/lib/Drupal/Core/Field/Plugin/Field/FieldFormatter/StringFormatter.php index 320005cee..ef2f43c28 100644 --- a/web/core/lib/Drupal/Core/Field/Plugin/Field/FieldFormatter/StringFormatter.php +++ b/web/core/lib/Drupal/Core/Field/Plugin/Field/FieldFormatter/StringFormatter.php @@ -124,7 +124,6 @@ class StringFormatter extends FormatterBase implements ContainerFactoryPluginInt $elements = []; $url = NULL; if ($this->getSetting('link_to_entity')) { - // For the default revision this falls back to 'canonical'. $url = $this->getEntityUrl($items->getEntity()); } @@ -173,8 +172,11 @@ class StringFormatter extends FormatterBase implements ContainerFactoryPluginInt * The URI elements of the entity. */ protected function getEntityUrl(EntityInterface $entity) { - // For the default revision this falls back to 'canonical'. - return $entity->toUrl('revision'); + // For the default revision, the 'revision' link template falls back to + // 'canonical'. + // @see \Drupal\Core\Entity\Entity::toUrl() + $rel = $entity->getEntityType()->hasLinkTemplate('revision') ? 'revision' : 'canonical'; + return $entity->toUrl($rel); } } diff --git a/web/core/lib/Drupal/Core/Layout/LayoutPluginManager.php b/web/core/lib/Drupal/Core/Layout/LayoutPluginManager.php index a5a465293..2cd0c6895 100644 --- a/web/core/lib/Drupal/Core/Layout/LayoutPluginManager.php +++ b/web/core/lib/Drupal/Core/Layout/LayoutPluginManager.php @@ -13,6 +13,7 @@ use Drupal\Core\Plugin\Discovery\ContainerDerivativeDiscoveryDecorator; use Drupal\Core\Plugin\Discovery\YamlDiscoveryDecorator; use Drupal\Core\Layout\Annotation\Layout; use Drupal\Core\Plugin\FilteredPluginManagerTrait; +use Drupal\Core\StringTranslation\TranslatableMarkup; /** * Provides a plugin manager for layouts. @@ -71,6 +72,10 @@ class LayoutPluginManager extends DefaultPluginManager implements LayoutPluginMa if (!$this->discovery) { $discovery = new AnnotatedClassDiscovery($this->subdir, $this->namespaces, $this->pluginDefinitionAnnotationName, $this->additionalAnnotationNamespaces); $discovery = new YamlDiscoveryDecorator($discovery, 'layouts', $this->moduleHandler->getModuleDirectories() + $this->themeHandler->getThemeDirectories()); + $discovery + ->addTranslatableProperty('label') + ->addTranslatableProperty('description') + ->addTranslatableProperty('category'); $discovery = new AnnotationBridgeDecorator($discovery, $this->pluginDefinitionAnnotationName); $discovery = new ContainerDerivativeDiscoveryDecorator($discovery); $this->discovery = $discovery; @@ -140,6 +145,15 @@ class LayoutPluginManager extends DefaultPluginManager implements LayoutPluginMa if (!$definition->getDefaultRegion()) { $definition->setDefaultRegion(key($definition->getRegions())); } + // Makes sure region names are translatable. + $regions = array_map(function ($region) { + if (!$region['label'] instanceof TranslatableMarkup) { + // Region labels from YAML discovery needs translation. + $region['label'] = new TranslatableMarkup($region['label'], [], ['context' => 'layout_region']); + } + return $region; + }, $definition->getRegions()); + $definition->setRegions($regions); } /** diff --git a/web/core/lib/Drupal/Core/Mail/MailManager.php b/web/core/lib/Drupal/Core/Mail/MailManager.php index bbc0432c6..5cf3c9ea0 100644 --- a/web/core/lib/Drupal/Core/Mail/MailManager.php +++ b/web/core/lib/Drupal/Core/Mail/MailManager.php @@ -5,7 +5,7 @@ namespace Drupal\Core\Mail; use Drupal\Component\Render\MarkupInterface; use Drupal\Component\Render\PlainTextOutput; use Drupal\Component\Utility\Html; -use Drupal\Component\Utility\Unicode; +use Drupal\Component\Utility\Mail as MailHelper; use Drupal\Core\Logger\LoggerChannelFactoryInterface; use Drupal\Core\Messenger\MessengerTrait; use Drupal\Core\Plugin\DefaultPluginManager; @@ -254,12 +254,8 @@ class MailManager extends DefaultPluginManager implements MailManagerInterface { // Return-Path headers should have a domain authorized to use the // originating SMTP server. $headers['Sender'] = $headers['Return-Path'] = $site_mail; - // Headers are usually encoded in the mail plugin that implements - // \Drupal\Core\Mail\MailInterface::mail(), for example, - // \Drupal\Core\Mail\Plugin\Mail\PhpMail::mail(). The site name must be - // encoded here to prevent mail plugins from encoding the email address, - // which would break the header. - $headers['From'] = Unicode::mimeHeaderEncode($site_config->get('name'), TRUE) . ' <' . $site_mail . '>'; + // Make sure the site-name is a RFC-2822 compliant 'display-name'. + $headers['From'] = MailHelper::formatDisplayName($site_config->get('name')) . ' <' . $site_mail . '>'; if ($reply) { $headers['Reply-to'] = $reply; } diff --git a/web/core/lib/Drupal/Core/Path/AliasStorage.php b/web/core/lib/Drupal/Core/Path/AliasStorage.php index a02bc3534..b68984871 100644 --- a/web/core/lib/Drupal/Core/Path/AliasStorage.php +++ b/web/core/lib/Drupal/Core/Path/AliasStorage.php @@ -106,11 +106,11 @@ class AliasStorage implements AliasStorageInterface { $this->catchException($e); $original = FALSE; } - $fields['pid'] = $pid; $query = $this->connection->update(static::TABLE) ->fields($fields) ->condition('pid', $pid); $pid = $query->execute(); + $fields['pid'] = $pid; $fields['original'] = $original; $operation = 'update'; } diff --git a/web/core/lib/Drupal/Core/Plugin/ContextAwarePluginBase.php b/web/core/lib/Drupal/Core/Plugin/ContextAwarePluginBase.php index 80f10d202..ed8abf17f 100644 --- a/web/core/lib/Drupal/Core/Plugin/ContextAwarePluginBase.php +++ b/web/core/lib/Drupal/Core/Plugin/ContextAwarePluginBase.php @@ -70,7 +70,7 @@ abstract class ContextAwarePluginBase extends ComponentContextAwarePluginBase im * {@inheritdoc} */ public function setContextValue($name, $value) { - $this->context[$name] = Context::createFromContext($this->getContext($name), $value); + $this->setContext($name, Context::createFromContext($this->getContext($name), $value)); return $this; } diff --git a/web/core/lib/Drupal/Core/Render/Element/StatusMessages.php b/web/core/lib/Drupal/Core/Render/Element/StatusMessages.php index a5a284298..d8627343e 100644 --- a/web/core/lib/Drupal/Core/Render/Element/StatusMessages.php +++ b/web/core/lib/Drupal/Core/Render/Element/StatusMessages.php @@ -76,7 +76,9 @@ class StatusMessages extends RenderElement { public static function renderMessages($type = NULL) { $render = []; if (isset($type)) { - $messages = \Drupal::messenger()->deleteByType($type); + $messages = [ + $type => \Drupal::messenger()->deleteByType($type), + ]; } else { $messages = \Drupal::messenger()->deleteAll(); diff --git a/web/core/lib/Drupal/Core/Render/PreviewFallbackInterface.php b/web/core/lib/Drupal/Core/Render/PreviewFallbackInterface.php new file mode 100644 index 000000000..cbaf6ac20 --- /dev/null +++ b/web/core/lib/Drupal/Core/Render/PreviewFallbackInterface.php @@ -0,0 +1,21 @@ +currentPath->getPath())); } $collection = $this->applyRouteFilters($collection, $request); + $collection = $this->applyFitOrder($collection); if ($ret = $this->matchCollection(rawurldecode($this->currentPath->getPath($request)), $collection)) { return $this->applyRouteEnhancers($ret, $request); @@ -286,6 +287,44 @@ class Router extends UrlMatcher implements RequestMatcherInterface, RouterInterf return $collection; } + /** + * Reapplies the fit order to a RouteCollection object. + * + * Route filters can reorder route collections. For example, routes with an + * explicit _format requirement will be preferred. This can result in a less + * fit route being used. For example, as a result of filtering /user/% comes + * before /user/login. In order to not break this fundamental property of + * routes, we need to reapply the fit order. We also need to ensure that order + * within each group of the same fit is preserved. + * + * @param \Symfony\Component\Routing\RouteCollection $collection + * The route collection. + * + * @return \Symfony\Component\Routing\RouteCollection + * The reordered route collection. + */ + protected function applyFitOrder(RouteCollection $collection) { + $buckets = []; + // Sort all the routes by fit descending. + foreach ($collection->all() as $name => $route) { + $fit = $route->compile()->getFit(); + $buckets += [$fit => []]; + $buckets[$fit][] = [$name, $route]; + } + krsort($buckets); + + $flattened = array_reduce($buckets, 'array_merge', []); + + // Add them back onto a new route collection. + $collection = new RouteCollection(); + foreach ($flattened as $pair) { + $name = $pair[0]; + $route = $pair[1]; + $collection->add($name, $route); + } + return $collection; + } + /** * {@inheritdoc} */ diff --git a/web/core/lib/Drupal/Core/Session/SessionManager.php b/web/core/lib/Drupal/Core/Session/SessionManager.php index 607103109..798139867 100644 --- a/web/core/lib/Drupal/Core/Session/SessionManager.php +++ b/web/core/lib/Drupal/Core/Session/SessionManager.php @@ -218,6 +218,11 @@ class SessionManager extends NativeSessionStorage implements SessionManagerInter if ($this->isStarted()) { $old_session_id = $this->getId(); + // Save and close the old session. Call the parent method to avoid issue + // with session destruction due to the session being considered obsolete. + parent::save(); + // Ensure the session is reloaded correctly. + $this->startedLazy = TRUE; } session_id(Crypt::randomBytesBase64()); @@ -230,10 +235,7 @@ class SessionManager extends NativeSessionStorage implements SessionManagerInter $this->migrateStoredSession($old_session_id); } - if (!$this->isStarted()) { - // Start the session when it doesn't exist yet. - $this->startNow(); - } + $this->startNow(); } /** diff --git a/web/core/lib/Drupal/Core/TempStore/PrivateTempStore.php b/web/core/lib/Drupal/Core/TempStore/PrivateTempStore.php index ea7ec3fc3..606a0dc9e 100644 --- a/web/core/lib/Drupal/Core/TempStore/PrivateTempStore.php +++ b/web/core/lib/Drupal/Core/TempStore/PrivateTempStore.php @@ -2,6 +2,7 @@ namespace Drupal\Core\TempStore; +use Drupal\Core\DependencyInjection\DependencySerializationTrait; use Drupal\Core\KeyValueStore\KeyValueStoreExpirableInterface; use Drupal\Core\Lock\LockBackendInterface; use Drupal\Core\Session\AccountProxyInterface; @@ -27,6 +28,7 @@ use Symfony\Component\HttpFoundation\RequestStack; * \Drupal\Core\TempStore\SharedTempStore. */ class PrivateTempStore { + use DependencySerializationTrait; /** * The key/value storage object used for this data. diff --git a/web/core/lib/Drupal/Core/TypedData/Validation/RecursiveContextualValidator.php b/web/core/lib/Drupal/Core/TypedData/Validation/RecursiveContextualValidator.php index a5d453ae5..449996710 100644 --- a/web/core/lib/Drupal/Core/TypedData/Validation/RecursiveContextualValidator.php +++ b/web/core/lib/Drupal/Core/TypedData/Validation/RecursiveContextualValidator.php @@ -126,10 +126,16 @@ class RecursiveContextualValidator implements ContextualValidatorInterface { $metadata = $this->metadataFactory->getMetadataFor($data); $cache_key = spl_object_hash($data); $property_path = $is_root_call ? '' : PropertyPath::append($previous_path, $data->getName()); + + // Prefer a specific instance of the typed data manager stored by the data + // if it is available. This is necessary for specialized typed data objects, + // for example those using the typed config subclass of the manager. + $typed_data_manager = method_exists($data, 'getTypedDataManager') ? $data->getTypedDataManager() : $this->typedDataManager; + // Pass the canonical representation of the data as validated value to // constraint validators, such that they do not have to care about Typed // Data. - $value = $this->typedDataManager->getCanonicalRepresentation($data); + $value = $typed_data_manager->getCanonicalRepresentation($data); $this->context->setNode($value, $data, $metadata, $property_path); if (isset($constraints) || !$this->context->isGroupValidated($cache_key, Constraint::DEFAULT_GROUP)) { diff --git a/web/core/misc/tableheader.es6.js b/web/core/misc/tableheader.es6.js index 9e26be5fc..c43d1dece 100644 --- a/web/core/misc/tableheader.es6.js +++ b/web/core/misc/tableheader.es6.js @@ -326,4 +326,4 @@ // Expose constructor in the public space. Drupal.TableHeader = TableHeader; -})(jQuery, Drupal, window.parent.Drupal.displace); +})(jQuery, Drupal, window.Drupal.displace); diff --git a/web/core/misc/tableheader.js b/web/core/misc/tableheader.js index 1fd60086d..d5ad0a235 100644 --- a/web/core/misc/tableheader.js +++ b/web/core/misc/tableheader.js @@ -164,4 +164,4 @@ }); Drupal.TableHeader = TableHeader; -})(jQuery, Drupal, window.parent.Drupal.displace); \ No newline at end of file +})(jQuery, Drupal, window.Drupal.displace); \ No newline at end of file diff --git a/web/core/modules/aggregator/src/Tests/AggregatorTestBase.php b/web/core/modules/aggregator/src/Tests/AggregatorTestBase.php index f42248975..00827a223 100644 --- a/web/core/modules/aggregator/src/Tests/AggregatorTestBase.php +++ b/web/core/modules/aggregator/src/Tests/AggregatorTestBase.php @@ -70,9 +70,9 @@ abstract class AggregatorTestBase extends WebTestBase { $view_link = $this->xpath('//div[@class="messages"]//a[contains(@href, :href)]', [':href' => 'aggregator/sources/']); $this->assert(isset($view_link), 'The message area contains a link to a feed'); - $fid = db_query("SELECT fid FROM {aggregator_feed} WHERE title = :title AND url = :url", [':title' => $edit['title[0][value]'], ':url' => $edit['url[0][value]']])->fetchField(); - $this->assertTrue(!empty($fid), 'The feed found in database.'); - return Feed::load($fid); + $fids = \Drupal::entityQuery('aggregator_feed')->condition('title', $edit['title[0][value]'])->condition('url', $edit['url[0][value]'])->execute(); + $this->assertNotEmpty($fids, 'The feed found in database.'); + return Feed::load(array_values($fids)[0]); } /** @@ -179,10 +179,10 @@ abstract class AggregatorTestBase extends WebTestBase { $this->clickLink('Update items'); // Ensure we have the right number of items. - $result = db_query('SELECT iid FROM {aggregator_item} WHERE fid = :fid', [':fid' => $feed->id()]); + $iids = \Drupal::entityQuery('aggregator_item')->condition('fid', $feed->id())->execute(); $feed->items = []; - foreach ($result as $item) { - $feed->items[] = $item->iid; + foreach ($iids as $iid) { + $feed->items[] = $iid; } if ($expected_count !== NULL) { @@ -211,11 +211,12 @@ abstract class AggregatorTestBase extends WebTestBase { * Expected number of feed items. */ public function updateAndDelete(FeedInterface $feed, $expected_count) { + $count_query = \Drupal::entityQuery('aggregator_item')->condition('fid', $feed->id())->count(); $this->updateFeedItems($feed, $expected_count); - $count = db_query('SELECT COUNT(*) FROM {aggregator_item} WHERE fid = :fid', [':fid' => $feed->id()])->fetchField(); + $count = $count_query->execute(); $this->assertTrue($count); $this->deleteFeedItems($feed); - $count = db_query('SELECT COUNT(*) FROM {aggregator_item} WHERE fid = :fid', [':fid' => $feed->id()])->fetchField(); + $count = $count_query->execute(); $this->assertTrue($count == 0); } @@ -231,7 +232,7 @@ abstract class AggregatorTestBase extends WebTestBase { * TRUE if feed is unique. */ public function uniqueFeed($feed_name, $feed_url) { - $result = db_query("SELECT COUNT(*) FROM {aggregator_feed} WHERE title = :title AND url = :url", [':title' => $feed_name, ':url' => $feed_url])->fetchField(); + $result = \Drupal::entityQuery('aggregator_feed')->condition('title', $feed_name)->condition('url', $feed_url)->count()->execute(); return (1 == $result); } diff --git a/web/core/modules/aggregator/tests/src/Functional/AggregatorCronTest.php b/web/core/modules/aggregator/tests/src/Functional/AggregatorCronTest.php index bb7c90ae3..4a3ca0b07 100644 --- a/web/core/modules/aggregator/tests/src/Functional/AggregatorCronTest.php +++ b/web/core/modules/aggregator/tests/src/Functional/AggregatorCronTest.php @@ -20,31 +20,23 @@ class AggregatorCronTest extends AggregatorTestBase { // Create feed and test basic updating on cron. $this->createSampleNodes(); $feed = $this->createFeed(); + $count_query = \Drupal::entityQuery('aggregator_item')->condition('fid', $feed->id())->count(); + $this->cronRun(); - $this->assertEqual(5, db_query('SELECT COUNT(*) FROM {aggregator_item} WHERE fid = :fid', [':fid' => $feed->id()])->fetchField()); + $this->assertEqual(5, $count_query->execute()); $this->deleteFeedItems($feed); - $this->assertEqual(0, db_query('SELECT COUNT(*) FROM {aggregator_item} WHERE fid = :fid', [':fid' => $feed->id()])->fetchField()); + $this->assertEqual(0, $count_query->execute()); $this->cronRun(); - $this->assertEqual(5, db_query('SELECT COUNT(*) FROM {aggregator_item} WHERE fid = :fid', [':fid' => $feed->id()])->fetchField()); + $this->assertEqual(5, $count_query->execute()); // Test feed locking when queued for update. $this->deleteFeedItems($feed); - db_update('aggregator_feed') - ->condition('fid', $feed->id()) - ->fields([ - 'queued' => REQUEST_TIME, - ]) - ->execute(); + $feed->setQueuedTime(REQUEST_TIME)->save(); $this->cronRun(); - $this->assertEqual(0, db_query('SELECT COUNT(*) FROM {aggregator_item} WHERE fid = :fid', [':fid' => $feed->id()])->fetchField()); - db_update('aggregator_feed') - ->condition('fid', $feed->id()) - ->fields([ - 'queued' => 0, - ]) - ->execute(); + $this->assertEqual(0, $count_query->execute()); + $feed->setQueuedTime(0)->save(); $this->cronRun(); - $this->assertEqual(5, db_query('SELECT COUNT(*) FROM {aggregator_item} WHERE fid = :fid', [':fid' => $feed->id()])->fetchField()); + $this->assertEqual(5, $count_query->execute()); } } diff --git a/web/core/modules/aggregator/tests/src/Functional/AggregatorTestBase.php b/web/core/modules/aggregator/tests/src/Functional/AggregatorTestBase.php index ddd98bb82..71c19031a 100644 --- a/web/core/modules/aggregator/tests/src/Functional/AggregatorTestBase.php +++ b/web/core/modules/aggregator/tests/src/Functional/AggregatorTestBase.php @@ -67,9 +67,9 @@ abstract class AggregatorTestBase extends BrowserTestBase { $view_link = $this->xpath('//div[@class="messages"]//a[contains(@href, :href)]', [':href' => 'aggregator/sources/']); $this->assert(isset($view_link), 'The message area contains a link to a feed'); - $fid = db_query("SELECT fid FROM {aggregator_feed} WHERE title = :title AND url = :url", [':title' => $edit['title[0][value]'], ':url' => $edit['url[0][value]']])->fetchField(); - $this->assertTrue(!empty($fid), 'The feed found in database.'); - return Feed::load($fid); + $fids = \Drupal::entityQuery('aggregator_feed')->condition('title', $edit['title[0][value]'])->condition('url', $edit['url[0][value]'])->execute(); + $this->assertNotEmpty($fids, 'The feed found in database.'); + return Feed::load(array_values($fids)[0]); } /** @@ -176,10 +176,10 @@ abstract class AggregatorTestBase extends BrowserTestBase { $this->clickLink('Update items'); // Ensure we have the right number of items. - $result = db_query('SELECT iid FROM {aggregator_item} WHERE fid = :fid', [':fid' => $feed->id()]); + $iids = \Drupal::entityQuery('aggregator_item')->condition('fid', $feed->id())->execute(); $feed->items = []; - foreach ($result as $item) { - $feed->items[] = $item->iid; + foreach ($iids as $iid) { + $feed->items[] = $iid; } if ($expected_count !== NULL) { @@ -208,11 +208,12 @@ abstract class AggregatorTestBase extends BrowserTestBase { * Expected number of feed items. */ public function updateAndDelete(FeedInterface $feed, $expected_count) { + $count_query = \Drupal::entityQuery('aggregator_item')->condition('fid', $feed->id())->count(); $this->updateFeedItems($feed, $expected_count); - $count = db_query('SELECT COUNT(*) FROM {aggregator_item} WHERE fid = :fid', [':fid' => $feed->id()])->fetchField(); + $count = $count_query->execute(); $this->assertTrue($count); $this->deleteFeedItems($feed); - $count = db_query('SELECT COUNT(*) FROM {aggregator_item} WHERE fid = :fid', [':fid' => $feed->id()])->fetchField(); + $count = $count_query->execute(); $this->assertTrue($count == 0); } @@ -228,7 +229,7 @@ abstract class AggregatorTestBase extends BrowserTestBase { * TRUE if feed is unique. */ public function uniqueFeed($feed_name, $feed_url) { - $result = db_query("SELECT COUNT(*) FROM {aggregator_feed} WHERE title = :title AND url = :url", [':title' => $feed_name, ':url' => $feed_url])->fetchField(); + $result = \Drupal::entityQuery('aggregator_feed')->condition('title', $feed_name)->condition('url', $feed_url)->count()->execute(); return (1 == $result); } diff --git a/web/core/modules/aggregator/tests/src/Functional/DeleteFeedTest.php b/web/core/modules/aggregator/tests/src/Functional/DeleteFeedTest.php index 557720658..7f6a4087f 100644 --- a/web/core/modules/aggregator/tests/src/Functional/DeleteFeedTest.php +++ b/web/core/modules/aggregator/tests/src/Functional/DeleteFeedTest.php @@ -43,8 +43,8 @@ class DeleteFeedTest extends AggregatorTestBase { $this->assertResponse(404, 'Deleted feed source does not exist.'); // Check database for feed. - $result = db_query("SELECT COUNT(*) FROM {aggregator_feed} WHERE title = :title AND url = :url", [':title' => $feed1->label(), ':url' => $feed1->getUrl()])->fetchField(); - $this->assertFalse($result, 'Feed not found in database'); + $result = \Drupal::entityQuery('aggregator_feed')->condition('title', $feed1->label())->condition('url', $feed1->getUrl())->count()->execute(); + $this->assertEquals(0, $result, 'Feed not found in database'); } } diff --git a/web/core/modules/aggregator/tests/src/Functional/FeedParserTest.php b/web/core/modules/aggregator/tests/src/Functional/FeedParserTest.php index ecb2e50c7..e340b27a9 100644 --- a/web/core/modules/aggregator/tests/src/Functional/FeedParserTest.php +++ b/web/core/modules/aggregator/tests/src/Functional/FeedParserTest.php @@ -4,6 +4,7 @@ namespace Drupal\Tests\aggregator\Functional; use Drupal\Core\Url; use Drupal\aggregator\Entity\Feed; +use Drupal\aggregator\Entity\Item; /** * Tests the built-in feed parser with valid feed samples. @@ -57,16 +58,17 @@ class FeedParserTest extends AggregatorTestBase { $this->assertText('Atom-Powered Robots Run Amok'); $this->assertLinkByHref('http://example.org/2003/12/13/atom03'); $this->assertText('Some text.'); - $this->assertEqual('urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a', db_query('SELECT guid FROM {aggregator_item} WHERE link = :link', [':link' => 'http://example.org/2003/12/13/atom03'])->fetchField(), 'Atom entry id element is parsed correctly.'); + $iids = \Drupal::entityQuery('aggregator_item')->condition('link', 'http://example.org/2003/12/13/atom03')->execute(); + $item = Item::load(array_values($iids)[0]); + $this->assertEqual('urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a', $item->getGuid(), 'Atom entry id element is parsed correctly.'); // Check for second feed entry. $this->assertText('We tried to stop them, but we failed.'); $this->assertLinkByHref('http://example.org/2003/12/14/atom03'); $this->assertText('Some other text.'); - $db_guid = db_query('SELECT guid FROM {aggregator_item} WHERE link = :link', [ - ':link' => 'http://example.org/2003/12/14/atom03', - ])->fetchField(); - $this->assertEqual('urn:uuid:1225c695-cfb8-4ebb-bbbb-80da344efa6a', $db_guid, 'Atom entry id element is parsed correctly.'); + $iids = \Drupal::entityQuery('aggregator_item')->condition('link', 'http://example.org/2003/12/14/atom03')->execute(); + $item = Item::load(array_values($iids)[0]); + $this->assertEqual('urn:uuid:1225c695-cfb8-4ebb-bbbb-80da344efa6a', $item->getGuid(), 'Atom entry id element is parsed correctly.'); } /** diff --git a/web/core/modules/aggregator/tests/src/Functional/ImportOpmlTest.php b/web/core/modules/aggregator/tests/src/Functional/ImportOpmlTest.php index 2500b9ebb..f9099e0eb 100644 --- a/web/core/modules/aggregator/tests/src/Functional/ImportOpmlTest.php +++ b/web/core/modules/aggregator/tests/src/Functional/ImportOpmlTest.php @@ -2,6 +2,8 @@ namespace Drupal\Tests\aggregator\Functional; +use Drupal\aggregator\Entity\Feed; + /** * Tests OPML import. * @@ -44,7 +46,8 @@ class ImportOpmlTest extends AggregatorTestBase { * Submits form filled with invalid fields. */ public function validateImportFormFields() { - $before = db_query('SELECT COUNT(*) FROM {aggregator_feed}')->fetchField(); + $count_query = \Drupal::entityQuery('aggregator_feed')->count(); + $before = $count_query->execute(); $edit = []; $this->drupalPostForm('admin/config/services/aggregator/add/opml', $edit, t('Import')); @@ -62,7 +65,7 @@ class ImportOpmlTest extends AggregatorTestBase { $this->drupalPostForm('admin/config/services/aggregator/add/opml', $edit, t('Import')); $this->assertText(t('The URL invalidUrl://empty is not valid.'), 'Error if the URL is invalid.'); - $after = db_query('SELECT COUNT(*) FROM {aggregator_feed}')->fetchField(); + $after = $count_query->execute(); $this->assertEqual($before, $after, 'No feeds were added during the three last form submissions.'); } @@ -70,7 +73,8 @@ class ImportOpmlTest extends AggregatorTestBase { * Submits form with invalid, empty, and valid OPML files. */ protected function submitImportForm() { - $before = db_query('SELECT COUNT(*) FROM {aggregator_feed}')->fetchField(); + $count_query = \Drupal::entityQuery('aggregator_feed')->count(); + $before = $count_query->execute(); $form['files[upload]'] = $this->getInvalidOpml(); $this->drupalPostForm('admin/config/services/aggregator/add/opml', $form, t('Import')); @@ -80,10 +84,12 @@ class ImportOpmlTest extends AggregatorTestBase { $this->drupalPostForm('admin/config/services/aggregator/add/opml', $edit, t('Import')); $this->assertText(t('No new feed has been added.'), 'Attempting to load empty OPML from remote URL.'); - $after = db_query('SELECT COUNT(*) FROM {aggregator_feed}')->fetchField(); + $after = $count_query->execute(); $this->assertEqual($before, $after, 'No feeds were added during the two last form submissions.'); - db_delete('aggregator_feed')->execute(); + foreach (Feed::loadMultiple() as $feed) { + $feed->delete(); + } $feeds[0] = $this->getFeedEditArray(); $feeds[1] = $this->getFeedEditArray(); @@ -96,15 +102,15 @@ class ImportOpmlTest extends AggregatorTestBase { $this->assertRaw(t('A feed with the URL %url already exists.', ['%url' => $feeds[0]['url[0][value]']]), 'Verifying that a duplicate URL was identified'); $this->assertRaw(t('A feed named %title already exists.', ['%title' => $feeds[1]['title[0][value]']]), 'Verifying that a duplicate title was identified'); - $after = db_query('SELECT COUNT(*) FROM {aggregator_feed}')->fetchField(); + $after = $count_query->execute(); $this->assertEqual($after, 2, 'Verifying that two distinct feeds were added.'); - $feeds_from_db = db_query("SELECT title, url, refresh FROM {aggregator_feed}"); + $feed_entities = Feed::loadMultiple(); $refresh = TRUE; - foreach ($feeds_from_db as $feed) { - $title[$feed->url] = $feed->title; - $url[$feed->title] = $feed->url; - $refresh = $refresh && $feed->refresh == 900; + foreach ($feed_entities as $feed_entity) { + $title[$feed_entity->getUrl()] = $feed_entity->label(); + $url[$feed_entity->label()] = $feed_entity->getUrl(); + $refresh = $refresh && $feed_entity->getRefreshRate() == 900; } $this->assertEqual($title[$feeds[0]['url[0][value]']], $feeds[0]['title[0][value]'], 'First feed was added correctly.'); diff --git a/web/core/modules/aggregator/tests/src/Functional/UpdateFeedItemTest.php b/web/core/modules/aggregator/tests/src/Functional/UpdateFeedItemTest.php index a7ec0edd5..fd2a74244 100644 --- a/web/core/modules/aggregator/tests/src/Functional/UpdateFeedItemTest.php +++ b/web/core/modules/aggregator/tests/src/Functional/UpdateFeedItemTest.php @@ -3,6 +3,7 @@ namespace Drupal\Tests\aggregator\Functional; use Drupal\aggregator\Entity\Feed; +use Drupal\aggregator\Entity\Item; /** * Update feed items from a feed. @@ -43,26 +44,24 @@ class UpdateFeedItemTest extends AggregatorTestBase { $view_link = $this->xpath('//div[@class="messages"]//a[contains(@href, :href)]', [':href' => 'aggregator/sources/']); $this->assert(isset($view_link), 'The message area contains a link to a feed'); - $fid = db_query("SELECT fid FROM {aggregator_feed} WHERE url = :url", [':url' => $edit['url[0][value]']])->fetchField(); - $feed = Feed::load($fid); + $fids = \Drupal::entityQuery('aggregator_feed')->condition('url', $edit['url[0][value]'])->execute(); + $feed = Feed::load(array_values($fids)[0]); $feed->refreshItems(); - $before = db_query('SELECT timestamp FROM {aggregator_item} WHERE fid = :fid', [':fid' => $feed->id()])->fetchField(); + $iids = \Drupal::entityQuery('aggregator_item')->condition('fid', $feed->id())->execute(); + $before = Item::load(array_values($iids)[0])->getPostedTime(); // Sleep for 3 second. sleep(3); - db_update('aggregator_feed') - ->condition('fid', $feed->id()) - ->fields([ - 'checked' => 0, - 'hash' => '', - 'etag' => '', - 'modified' => 0, - ]) - ->execute(); + $feed + ->setLastCheckedTime(0) + ->setHash('') + ->setEtag('') + ->setLastModified(0) + ->save(); $feed->refreshItems(); - $after = db_query('SELECT timestamp FROM {aggregator_item} WHERE fid = :fid', [':fid' => $feed->id()])->fetchField(); + $after = Item::load(array_values($iids)[0])->getPostedTime(); $this->assertTrue($before === $after, format_string('Publish timestamp of feed item was not updated (@before === @after)', ['@before' => $before, '@after' => $after])); // Make sure updating items works even after uninstalling a module diff --git a/web/core/modules/block/js/block.admin.es6.js b/web/core/modules/block/js/block.admin.es6.js index 197bc4bea..ff4d8eb20 100644 --- a/web/core/modules/block/js/block.admin.es6.js +++ b/web/core/modules/block/js/block.admin.es6.js @@ -93,7 +93,8 @@ */ Drupal.behaviors.blockHighlightPlacement = { attach(context, settings) { - if (settings.blockPlacement) { + // Ensure that the block we are attempting to scroll to actually exists. + if (settings.blockPlacement && $('.js-block-placed').length) { $(context) .find('[data-drupal-selector="edit-blocks"]') .once('block-highlight') diff --git a/web/core/modules/block/js/block.admin.js b/web/core/modules/block/js/block.admin.js index 7cafed16a..641f12d3f 100644 --- a/web/core/modules/block/js/block.admin.js +++ b/web/core/modules/block/js/block.admin.js @@ -41,7 +41,7 @@ Drupal.behaviors.blockHighlightPlacement = { attach: function attach(context, settings) { - if (settings.blockPlacement) { + if (settings.blockPlacement && $('.js-block-placed').length) { $(context).find('[data-drupal-selector="edit-blocks"]').once('block-highlight').each(function () { var $container = $(this); diff --git a/web/core/modules/block/src/BlockListBuilder.php b/web/core/modules/block/src/BlockListBuilder.php index c1ccc504d..d2286fcdd 100644 --- a/web/core/modules/block/src/BlockListBuilder.php +++ b/web/core/modules/block/src/BlockListBuilder.php @@ -354,6 +354,23 @@ class BlockListBuilder extends ConfigEntityListBuilder implements FormInterface if (isset($operations['delete'])) { $operations['delete']['title'] = $this->t('Remove'); + // Block operation links should have the `block-placement` query string + // parameter removed to ensure that JavaScript does not receive a block + // name that has been recently removed. + foreach ($operations as $operation) { + /** @var \Drupal\Core\Url $url */ + $url = $operation['url']; + $query = $url->getOption('query'); + $destination = $query['destination']; + + $destinationUrl = Url::fromUserInput($destination); + $destinationQuery = $destinationUrl->getOption('query'); + unset($destinationQuery['block-placement']); + + $destinationUrl->setOption('query', $destinationQuery); + $query['destination'] = $destinationUrl->toString(); + $url->setOption('query', $query); + } } return $operations; } diff --git a/web/core/modules/block/src/Plugin/migrate/source/d7/BlockTranslation.php b/web/core/modules/block/src/Plugin/migrate/source/d7/BlockTranslation.php new file mode 100644 index 000000000..acf9e2b7c --- /dev/null +++ b/web/core/modules/block/src/Plugin/migrate/source/d7/BlockTranslation.php @@ -0,0 +1,99 @@ +select('i18n_string', 'i18n') + ->fields('i18n') + ->fields('b', [ + 'bid', + 'module', + 'delta', + 'theme', + 'status', + 'weight', + 'region', + 'custom', + 'visibility', + 'pages', + 'title', + 'cache', + 'i18n_mode', + ]) + ->fields('lt', [ + 'lid', + 'translation', + 'language', + 'plid', + 'plural', + 'i18n_status', + ]) + ->condition('i18n_mode', 1); + $query->leftjoin($this->blockTable, 'b', ('b.delta = i18n.objectid')); + $query->leftjoin('locales_target', 'lt', 'lt.lid = i18n.lid'); + return $query; + } + + /** + * {@inheritdoc} + */ + public function fields() { + return [ + 'bid' => $this->t('The block numeric identifier.'), + 'module' => $this->t('The module providing the block.'), + 'delta' => $this->t("The block's delta."), + 'theme' => $this->t('Which theme the block is placed in.'), + 'status' => $this->t('Block enabled status'), + 'weight' => $this->t('Block weight within region'), + 'region' => $this->t('Theme region within which the block is set'), + 'visibility' => $this->t('Visibility'), + 'pages' => $this->t('Pages list.'), + 'title' => $this->t('Block title.'), + 'cache' => $this->t('Cache rule.'), + 'i18n_mode' => $this->t('Multilingual mode'), + 'lid' => $this->t('Language string ID'), + 'textgroup' => $this->t('A module defined group of translations'), + 'context' => $this->t('Full string ID for quick search: type:objectid:property.'), + 'objectid' => $this->t('Object ID'), + 'type' => $this->t('Object type for this string'), + 'property' => $this->t('Object property for this string'), + 'objectindex' => $this->t('Integer value of Object ID'), + 'format' => $this->t('The {filter_format}.format of the string'), + 'translation' => $this->t('Translation'), + 'language' => $this->t('Language code'), + 'plid' => $this->t('Parent lid'), + 'plural' => $this->t('Plural index number'), + 'i18n_status' => $this->t('Translation needs update'), + ]; + } + + /** + * {@inheritdoc} + */ + public function getIds() { + $ids['delta']['type'] = 'string'; + $ids['delta']['alias'] = 'b'; + $ids['language']['type'] = 'string'; + return $ids; + } + +} diff --git a/web/core/modules/block/tests/src/Functional/BlockTest.php b/web/core/modules/block/tests/src/Functional/BlockTest.php index 53b48662c..9fb33573c 100644 --- a/web/core/modules/block/tests/src/Functional/BlockTest.php +++ b/web/core/modules/block/tests/src/Functional/BlockTest.php @@ -238,6 +238,30 @@ class BlockTest extends BlockTestBase { $this->assertNoRaw($block->id()); } + /** + * Tests the block operation links. + */ + public function testBlockOperationLinks() { + $this->drupalGet('admin/structure/block'); + // Go to the select block form. + $this->clickLink('Place block'); + // Select the first available block. + $this->clickLink('Place block'); + // Finally place the block + $this->submitForm([], 'Save block'); + + $url = $this->getUrl(); + $parsed = parse_url($url); + $this->assertContains('block-placement', $parsed['query']); + + $this->clickLink('Remove'); + $this->submitForm([], 'Remove'); + + $url = $this->getUrl(); + $parsed = parse_url($url); + $this->assertTrue(empty($parsed['query'])); + } + /** * Tests that the block form has a theme selector when not passed via the URL. */ diff --git a/web/core/modules/block/tests/src/Kernel/Migrate/d7/MigrateBlockContentTranslationTest.php b/web/core/modules/block/tests/src/Kernel/Migrate/d7/MigrateBlockContentTranslationTest.php new file mode 100644 index 000000000..0d082a8e0 --- /dev/null +++ b/web/core/modules/block/tests/src/Kernel/Migrate/d7/MigrateBlockContentTranslationTest.php @@ -0,0 +1,69 @@ +installConfig(['block']); + $this->installConfig(['block_content']); + $this->installEntitySchema('block_content'); + + $this->executeMigrations([ + 'language', + 'd7_filter_format', + 'block_content_type', + 'block_content_body_field', + 'd7_custom_block', + 'd7_user_role', + 'd7_block', + 'd7_block_translation', + ]); + block_rebuild(); + } + + /** + * Tests the migration of block title translation. + */ + public function testBlockContentTranslation() { + /** @var \Drupal\language\ConfigurableLanguageManagerInterface $language_manager */ + $language_manager = $this->container->get('language_manager'); + + $config = $language_manager->getLanguageConfigOverride('fr', 'block.block.bartik_user_login'); + $this->assertSame('fr - User login title', $config->get('settings.label')); + } + +} diff --git a/web/core/modules/block/tests/src/Kernel/Migrate/d7/MigrateBlockTest.php b/web/core/modules/block/tests/src/Kernel/Migrate/d7/MigrateBlockTest.php index 6f07dc9c2..d56b9ed19 100644 --- a/web/core/modules/block/tests/src/Kernel/Migrate/d7/MigrateBlockTest.php +++ b/web/core/modules/block/tests/src/Kernel/Migrate/d7/MigrateBlockTest.php @@ -112,10 +112,10 @@ class MigrateBlockTest extends MigrateDrupal7TestBase { public function testBlockMigration() { $this->assertEntity('bartik_system_main', 'system_main_block', [], '', 'content', 'bartik', 0, '', '0'); $this->assertEntity('bartik_search_form', 'search_form_block', [], '', 'sidebar_first', 'bartik', -1, '', '0'); - $this->assertEntity('bartik_user_login', 'user_login_block', [], '', 'sidebar_first', 'bartik', 0, '', '0'); + $this->assertEntity('bartik_user_login', 'user_login_block', [], '', 'sidebar_first', 'bartik', 0, 'User login title', 'visible'); $this->assertEntity('bartik_system_powered_by', 'system_powered_by_block', [], '', 'footer_fifth', 'bartik', 10, '', '0'); $this->assertEntity('seven_system_main', 'system_main_block', [], '', 'content', 'seven', 0, '', '0'); - $this->assertEntity('seven_user_login', 'user_login_block', [], '', 'content', 'seven', 10, '', '0'); + $this->assertEntity('seven_user_login', 'user_login_block', [], '', 'content', 'seven', 10, 'User login title', 'visible'); // The d7_custom_block migration should have migrated a block containing a // mildly amusing limerick. We'll need its UUID to determine diff --git a/web/core/modules/block/tests/src/Kernel/Plugin/migrate/source/d7/BlockTranslationTest.php b/web/core/modules/block/tests/src/Kernel/Plugin/migrate/source/d7/BlockTranslationTest.php new file mode 100644 index 000000000..01b2dced6 --- /dev/null +++ b/web/core/modules/block/tests/src/Kernel/Plugin/migrate/source/d7/BlockTranslationTest.php @@ -0,0 +1,147 @@ + 1, + 'module' => 'system', + 'delta' => 'main', + 'theme' => 'bartik', + 'status' => 1, + 'weight' => 0, + 'region' => 'content', + 'custom' => '0', + 'visibility' => 0, + 'pages' => '', + 'title' => '', + 'cache' => -1, + 'i18n_mode' => 0, + ], + [ + 'bid' => 2, + 'module' => 'system', + 'delta' => 'navigation', + 'theme' => 'bartik', + 'status' => 1, + 'weight' => 0, + 'region' => 'sidebar_first', + 'custom' => '0', + 'visibility' => 0, + 'pages' => '', + 'title' => 'Navigation', + 'cache' => -1, + 'i18n_mode' => 1, + ], + ]; + $tests[0]['source_data']['block_role'] = [ + [ + 'module' => 'block', + 'delta' => 1, + 'rid' => 2, + ], + [ + 'module' => 'block', + 'delta' => 2, + 'rid' => 2, + ], + [ + 'module' => 'block', + 'delta' => 2, + 'rid' => 100, + ], + ]; + $tests[0]['source_data']['i18n_string'] = [ + [ + 'lid' => 1, + 'textgroup' => 'block', + 'context' => '1', + 'objectid' => 'navigation', + 'type' => 'system', + 'property' => 'title', + 'objectindex' => 0, + 'format' => '', + ], + ]; + + $tests[0]['source_data']['locales_target'] = [ + [ + 'lid' => 1, + 'translation' => 'fr - Navigation', + 'language' => 'fr', + 'plid' => 0, + 'plural' => 0, + 'i18n_status' => 0, + ], + ]; + $tests[0]['source_data']['role'] = [ + [ + 'rid' => 2, + 'name' => 'authenticated user', + ], + ]; + $tests[0]['source_data']['system'] = [ + [ + 'filename' => 'modules/system/system.module', + 'name' => 'system', + 'type' => 'module', + 'owner' => '', + 'status' => '1', + 'throttle' => '0', + 'bootstrap' => '0', + 'schema_version' => '7055', + 'weight' => '0', + 'info' => 'a:0:{}', + ], + ]; + // The expected results. + $tests[0]['expected_data'] = [ + [ + 'bid' => 2, + 'module' => 'system', + 'delta' => 'navigation', + 'theme' => 'bartik', + 'status' => 1, + 'weight' => 0, + 'region' => 'sidebar_first', + 'custom' => '0', + 'visibility' => 0, + 'pages' => '', + 'title' => 'Navigation', + 'cache' => -1, + 'i18n_mode' => 1, + 'lid' => 1, + 'translation' => 'fr - Navigation', + 'language' => 'fr', + 'plid' => 0, + 'plural' => 0, + 'i18n_status' => 0, + ], + ]; + + return $tests; + } + +} diff --git a/web/core/modules/comment/tests/src/Kernel/Migrate/d7/MigrateCommentTypeTest.php b/web/core/modules/comment/tests/src/Kernel/Migrate/d7/MigrateCommentTypeTest.php index b0c96dab8..9a82b5b0c 100644 --- a/web/core/modules/comment/tests/src/Kernel/Migrate/d7/MigrateCommentTypeTest.php +++ b/web/core/modules/comment/tests/src/Kernel/Migrate/d7/MigrateCommentTypeTest.php @@ -46,6 +46,17 @@ class MigrateCommentTypeTest extends MigrateDrupal7TestBase { * Tests the migrated comment types. */ public function testMigration() { + $comment_fields = [ + 'comment' => 'Default comment setting', + 'comment_default_mode' => 'Default display mode', + 'comment_default_per_page' => 'Default comments per page', + 'comment_anonymous' => 'Anonymous commenting', + 'comment_subject_field' => 'Comment subject field', + 'comment_preview' => 'Preview comment', + 'comment_form_location' => 'Location of comment submission form', + ]; + $this->assertArraySubset($comment_fields, $this->migration->getSourcePlugin()->fields()); + $this->assertEntity('comment_node_article', 'Article comment'); $this->assertEntity('comment_node_blog', 'Blog entry comment'); $this->assertEntity('comment_node_book', 'Book page comment'); diff --git a/web/core/modules/content_moderation/src/EntityOperations.php b/web/core/modules/content_moderation/src/EntityOperations.php index 9ab40eac0..8c6d33250 100644 --- a/web/core/modules/content_moderation/src/EntityOperations.php +++ b/web/core/modules/content_moderation/src/EntityOperations.php @@ -184,11 +184,16 @@ class EntityOperations implements ContainerInjectionInterface { // Sync translations. if ($entity->getEntityType()->hasKey('langcode')) { $entity_langcode = $entity->language()->getId(); - if (!$content_moderation_state->hasTranslation($entity_langcode)) { - $content_moderation_state->addTranslation($entity_langcode); + if ($entity->isDefaultTranslation()) { + $content_moderation_state->langcode = $entity_langcode; } - if ($content_moderation_state->language()->getId() !== $entity_langcode) { - $content_moderation_state = $content_moderation_state->getTranslation($entity_langcode); + else { + if (!$content_moderation_state->hasTranslation($entity_langcode)) { + $content_moderation_state->addTranslation($entity_langcode); + } + if ($content_moderation_state->language()->getId() !== $entity_langcode) { + $content_moderation_state = $content_moderation_state->getTranslation($entity_langcode); + } } } diff --git a/web/core/modules/content_moderation/src/Plugin/Field/ModerationStateFieldItemList.php b/web/core/modules/content_moderation/src/Plugin/Field/ModerationStateFieldItemList.php index 64c5ad849..357c60c9b 100644 --- a/web/core/modules/content_moderation/src/Plugin/Field/ModerationStateFieldItemList.php +++ b/web/core/modules/content_moderation/src/Plugin/Field/ModerationStateFieldItemList.php @@ -93,7 +93,7 @@ class ModerationStateFieldItemList extends FieldItemList { if ($entity->getEntityType()->hasKey('langcode')) { $langcode = $entity->language()->getId(); if (!$content_moderation_state->hasTranslation($langcode)) { - $content_moderation_state->addTranslation($langcode); + $content_moderation_state->addTranslation($langcode, $content_moderation_state->toArray()); } if ($content_moderation_state->language()->getId() !== $langcode) { $content_moderation_state = $content_moderation_state->getTranslation($langcode); diff --git a/web/core/modules/content_moderation/tests/src/Functional/ModerationContentTranslationTest.php b/web/core/modules/content_moderation/tests/src/Functional/ModerationContentTranslationTest.php new file mode 100644 index 000000000..4afa97de6 --- /dev/null +++ b/web/core/modules/content_moderation/tests/src/Functional/ModerationContentTranslationTest.php @@ -0,0 +1,109 @@ +drupalLogin($this->rootUser); + // Create an Article content type. + $this->drupalCreateContentType(['type' => 'article', 'name' => 'Article'])->save(); + $edit = [ + 'predefined_langcode' => 'fr', + ]; + $this->drupalPostForm('admin/config/regional/language/add', $edit, 'Add language'); + // Enable content translation on articles. + $this->drupalGet('admin/config/regional/content-language'); + $edit = [ + 'entity_types[node]' => TRUE, + 'settings[node][article][translatable]' => TRUE, + 'settings[node][article][settings][language][language_alterable]' => TRUE, + ]; + $this->drupalPostForm(NULL, $edit, 'Save configuration'); + // Adding languages requires a container rebuild in the test running + // environment so that multilingual services are used. + $this->rebuildContainer(); + } + + /** + * Tests existing translations being edited after enabling content moderation. + */ + public function testModerationWithExistingContent() { + // Create a published article in English. + $edit = [ + 'title[0][value]' => 'Published English node', + 'langcode[0][value]' => 'en', + ]; + $this->drupalPostForm('node/add/article', $edit, 'Save'); + $this->assertSession()->pageTextContains('Article Published English node has been created.'); + $english_node = $this->drupalGetNodeByTitle('Published English node'); + + // Add a French translation. + $this->drupalGet('node/' . $english_node->id() . '/translations'); + $this->clickLink('Add'); + $edit = [ + 'title[0][value]' => 'Published French node', + ]; + $this->drupalPostForm(NULL, $edit, 'Save (this translation)'); + $this->assertSession()->pageTextContains('Article Published French node has been updated.'); + + // Install content moderation and enable moderation on Article node type. + \Drupal::service('module_installer')->install(['content_moderation']); + $workflow = $this->createEditorialWorkflow(); + $workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'article'); + $workflow->save(); + $this->drupalLogin($this->rootUser); + + // Edit the English node. + $this->drupalGet('node/' . $english_node->id() . '/edit'); + $this->assertSession()->statusCodeEquals(200); + $edit = [ + 'title[0][value]' => 'Published English new node', + ]; + $this->drupalPostForm(NULL, $edit, 'Save'); + $this->assertSession()->statusCodeEquals(200); + $this->assertSession()->pageTextContains('Article Published English new node has been updated.'); + // Edit the French translation. + $this->drupalGet('fr/node/' . $english_node->id() . '/edit'); + $this->assertSession()->statusCodeEquals(200); + $edit = [ + 'title[0][value]' => 'Published French new node', + ]; + $this->drupalPostForm(NULL, $edit, 'Save (this translation)'); + $this->assertSession()->statusCodeEquals(200); + $this->assertSession()->pageTextContains('Article Published French new node has been updated.'); + } + +} diff --git a/web/core/modules/content_moderation/tests/src/Kernel/ContentModerationStateTest.php b/web/core/modules/content_moderation/tests/src/Kernel/ContentModerationStateTest.php index 8574ad6a1..4b7d75be4 100644 --- a/web/core/modules/content_moderation/tests/src/Kernel/ContentModerationStateTest.php +++ b/web/core/modules/content_moderation/tests/src/Kernel/ContentModerationStateTest.php @@ -296,7 +296,7 @@ class ContentModerationStateTest extends KernelTestBase { // Create a French translation. $french_node = $english_node->addTranslation('fr', ['title' => 'French title']); $french_node->setUnpublished(); - // Revision 1 (fr). + // Revision 2 (fr). $french_node->save(); $french_node = $this->reloadEntity($english_node)->getTranslation('fr'); $this->assertEquals('draft', $french_node->moderation_state->value); @@ -305,7 +305,7 @@ class ContentModerationStateTest extends KernelTestBase { // Move English node to create another draft. $english_node = $this->reloadEntity($english_node); $english_node->moderation_state->value = 'draft'; - // Revision 2 (en, fr). + // Revision 3 (en, fr). $english_node->save(); $english_node = $this->reloadEntity($english_node); $this->assertEquals('draft', $english_node->moderation_state->value); @@ -316,7 +316,7 @@ class ContentModerationStateTest extends KernelTestBase { // Publish the French node. $french_node->moderation_state->value = 'published'; - // Revision 3 (en, fr). + // Revision 4 (en, fr). $french_node->save(); $french_node = $this->reloadEntity($french_node)->getTranslation('fr'); $this->assertTrue($french_node->isPublished()); @@ -327,7 +327,7 @@ class ContentModerationStateTest extends KernelTestBase { // Publish the English node. $english_node->moderation_state->value = 'published'; - // Revision 4 (en, fr). + // Revision 5 (en, fr). $english_node->save(); $english_node = $this->reloadEntity($english_node); $this->assertTrue($english_node->isPublished()); @@ -336,15 +336,15 @@ class ContentModerationStateTest extends KernelTestBase { $french_node = $this->reloadEntity($english_node)->getTranslation('fr'); $this->assertTrue($french_node->isPublished()); $french_node->moderation_state->value = 'draft'; - // Revision 5 (en, fr). + // Revision 6 (en, fr). $french_node->save(); - $french_node = $this->reloadEntity($english_node, 5)->getTranslation('fr'); + $french_node = $this->reloadEntity($english_node, 6)->getTranslation('fr'); $this->assertFalse($french_node->isPublished()); $this->assertTrue($french_node->getTranslation('en')->isPublished()); // Republish the French node. $french_node->moderation_state->value = 'published'; - // Revision 6 (en, fr). + // Revision 7 (en, fr). $french_node->save(); $french_node = $this->reloadEntity($english_node)->getTranslation('fr'); $this->assertTrue($french_node->isPublished()); @@ -353,7 +353,7 @@ class ContentModerationStateTest extends KernelTestBase { $content_moderation_state = ContentModerationState::load(1); $content_moderation_state->set('moderation_state', 'draft'); $content_moderation_state->setNewRevision(TRUE); - // Revision 7 (en, fr). + // Revision 8 (en, fr). $content_moderation_state->save(); $english_node = $this->reloadEntity($french_node, $french_node->getRevisionId() + 1); @@ -366,12 +366,12 @@ class ContentModerationStateTest extends KernelTestBase { $content_moderation_state = $content_moderation_state->getTranslation('fr'); $content_moderation_state->set('moderation_state', 'draft'); $content_moderation_state->setNewRevision(TRUE); - // Revision 8 (en, fr). + // Revision 9 (en, fr). $content_moderation_state->save(); $english_node = $this->reloadEntity($english_node, $english_node->getRevisionId()); $this->assertEquals('draft', $english_node->moderation_state->value); - $french_node = $this->reloadEntity($english_node, '8')->getTranslation('fr'); + $french_node = $this->reloadEntity($english_node, '9')->getTranslation('fr'); $this->assertEquals('draft', $french_node->moderation_state->value); // Switching the moderation state to an unpublished state should update the // entity. @@ -380,7 +380,7 @@ class ContentModerationStateTest extends KernelTestBase { // Get the default english node. $english_node = $this->reloadEntity($english_node); $this->assertTrue($english_node->isPublished()); - $this->assertEquals(6, $english_node->getRevisionId()); + $this->assertEquals(7, $english_node->getRevisionId()); } /** @@ -416,25 +416,83 @@ class ContentModerationStateTest extends KernelTestBase { /** * Tests that entities with special languages can be moderated. + * + * @dataProvider moderationWithSpecialLanguagesTestCases */ - public function testModerationWithSpecialLanguages() { + public function testModerationWithSpecialLanguages($original_language, $updated_language) { $workflow = $this->createEditorialWorkflow(); $workflow->getTypePlugin()->addEntityTypeAndBundle('entity_test_rev', 'entity_test_rev'); $workflow->save(); // Create a test entity. $entity = EntityTestRev::create([ - 'langcode' => LanguageInterface::LANGCODE_NOT_SPECIFIED, + 'langcode' => $original_language, ]); $entity->save(); $this->assertEquals('draft', $entity->moderation_state->value); $entity->moderation_state->value = 'published'; + $entity->langcode = $updated_language; $entity->save(); $this->assertEquals('published', EntityTestRev::load($entity->id())->moderation_state->value); } + /** + * Test cases for ::testModerationWithSpecialLanguages(). + */ + public function moderationWithSpecialLanguagesTestCases() { + return [ + 'Not specified to not specified' => [ + LanguageInterface::LANGCODE_NOT_SPECIFIED, + LanguageInterface::LANGCODE_NOT_SPECIFIED, + ], + 'English to not specified' => [ + 'en', + LanguageInterface::LANGCODE_NOT_SPECIFIED, + ], + 'Not specified to english' => [ + LanguageInterface::LANGCODE_NOT_SPECIFIED, + 'en', + ], + ]; + } + + /** + * Test changing the language of content without adding a translation. + */ + public function testChangingContentLangcode() { + ConfigurableLanguage::createFromLangcode('fr')->save(); + NodeType::create([ + 'type' => 'test_type', + ])->save(); + $workflow = $this->createEditorialWorkflow(); + $workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'test_type'); + $workflow->save(); + + $entity = Node::create([ + 'title' => 'Test node', + 'langcode' => 'en', + 'type' => 'test_type', + ]); + $entity->save(); + + $content_moderation_state = ContentModerationState::loadFromModeratedEntity($entity); + $this->assertCount(1, $entity->getTranslationLanguages()); + $this->assertCount(1, $content_moderation_state->getTranslationLanguages()); + $this->assertEquals('en', $entity->langcode->value); + $this->assertEquals('en', $content_moderation_state->langcode->value); + + $entity->langcode = 'fr'; + $entity->save(); + + $content_moderation_state = ContentModerationState::loadFromModeratedEntity($entity); + $this->assertCount(1, $entity->getTranslationLanguages()); + $this->assertCount(1, $content_moderation_state->getTranslationLanguages()); + $this->assertEquals('fr', $entity->langcode->value); + $this->assertEquals('fr', $content_moderation_state->langcode->value); + } + /** * Tests that a non-translatable entity type with a langcode can be moderated. */ diff --git a/web/core/modules/content_moderation/tests/src/Kernel/ModerationStateFieldItemListTest.php b/web/core/modules/content_moderation/tests/src/Kernel/ModerationStateFieldItemListTest.php index 7b57bd704..0c8db872a 100644 --- a/web/core/modules/content_moderation/tests/src/Kernel/ModerationStateFieldItemListTest.php +++ b/web/core/modules/content_moderation/tests/src/Kernel/ModerationStateFieldItemListTest.php @@ -3,9 +3,11 @@ namespace Drupal\Tests\content_moderation\Kernel; use Drupal\KernelTests\KernelTestBase; +use Drupal\language\Entity\ConfigurableLanguage; use Drupal\node\Entity\Node; use Drupal\node\Entity\NodeType; use Drupal\Tests\content_moderation\Traits\ContentModerationTestTrait; +use Drupal\workflows\Entity\Workflow; /** * @coversDefaultClass \Drupal\content_moderation\Plugin\Field\ModerationStateFieldItemList @@ -64,6 +66,8 @@ class ModerationStateFieldItemListTest extends KernelTestBase { $this->testNode->save(); \Drupal::entityTypeManager()->getStorage('node')->resetCache(); $this->testNode = Node::load($this->testNode->id()); + + ConfigurableLanguage::createFromLangcode('de')->save(); } /** @@ -332,4 +336,37 @@ class ModerationStateFieldItemListTest extends KernelTestBase { ]; } + /** + * Test the field item list when used with existing unmoderated content. + */ + public function testWithExistingUnmoderatedContent() { + $node = Node::create([ + 'title' => 'Test title', + 'type' => 'unmoderated', + ]); + $node->save(); + $translation = $node->addTranslation('de', $node->toArray()); + $translation->title = 'Translated'; + $translation->save(); + + $workflow = Workflow::load('editorial'); + $workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'unmoderated'); + $workflow->save(); + + // After enabling moderation, both the original node and translation should + // have a published moderation state. + $node = Node::load($node->id()); + $translation = $node->getTranslation('de'); + $this->assertEquals('published', $node->moderation_state->value); + $this->assertEquals('published', $translation->moderation_state->value); + + // After the node has been updated, both the original node and translation + // should still have a value. + $node->title = 'Updated title'; + $node->save(); + $translation = $node->getTranslation('de'); + $this->assertEquals('published', $node->moderation_state->value); + $this->assertEquals('published', $translation->moderation_state->value); + } + } diff --git a/web/core/modules/content_translation/migrations/d6_taxonomy_term_localized_translation.yml b/web/core/modules/content_translation/migrations/d6_taxonomy_term_localized_translation.yml new file mode 100644 index 000000000..980baf4ae --- /dev/null +++ b/web/core/modules/content_translation/migrations/d6_taxonomy_term_localized_translation.yml @@ -0,0 +1,44 @@ +id: d6_taxonomy_term_localized_translation +label: Taxonomy localized term translations +migration_tags: + - Drupal 6 + - Content + - Multilingual +source: + plugin: d6_term_localized_translation + translations: true +process: + # If you are using this file to build a custom migration consider removing + # the tid field to allow incremental migrations. + tid: tid + langcode: language + vid: + plugin: migration + migration: d6_taxonomy_vocabulary + source: vid + name: + - + plugin: callback + source: + - name_translated + - name + callable: array_filter + - + plugin: callback + callable: current + description: + - + plugin: callback + source: + - description_translated + - description + callable: array_filter + - + plugin: callback + callable: current +destination: + plugin: entity:taxonomy_term + translations: true +migration_dependencies: + required: + - d6_taxonomy_term diff --git a/web/core/modules/content_translation/migrations/d7_block_translation.yml b/web/core/modules/content_translation/migrations/d7_block_translation.yml new file mode 100644 index 000000000..a664ec17f --- /dev/null +++ b/web/core/modules/content_translation/migrations/d7_block_translation.yml @@ -0,0 +1,77 @@ +id: d7_block_translation +label: Block translation +migration_tags: + - Drupal 7 + - Configuration + - Multilingual +source: + plugin: d7_block_translation + constants: + dest_label: 'settings/label' +process: + multilingual: + plugin: skip_on_empty + source: i18n_mode + method: row + langcode: language + property: constants/dest_label + translation: translation + id: + - + plugin: migration_lookup + migration: d7_block + source: + - module + - delta + - + plugin: skip_on_empty + method: row + # The plugin process is copied from d7_block.yml + plugin: + - + plugin: static_map + bypass: true + source: + - module + - delta + map: + book: + navigation: book_navigation + comment: + recent: views_block:comments_recent-block_1 + forum: + active: forum_active_block + new: forum_new_block + # locale: + # 0: language_block + node: + syndicate: node_syndicate_block + search: + form: search_form_block + statistics: + popular: statistics_popular_block + system: + main: system_main_block + 'powered-by': system_powered_by_block + user: + login: user_login_block + # 1: system_menu_block:tools + new: views_block:who_s_new-block_1 + online: views_block:who_s_online-who_s_online_block + - + plugin: block_plugin_id + - + plugin: skip_on_empty + method: row + # The theme process is copied from d7_block.yml + theme: + plugin: block_theme + source: + - theme + - default_theme + - admin_theme +destination: + plugin: entity:block +migration_dependencies: + optional: + - d7_block diff --git a/web/core/modules/content_translation/tests/src/Kernel/Migrate/d6/MigrateTaxonomyTermTranslationTest.php b/web/core/modules/content_translation/tests/src/Kernel/Migrate/d6/MigrateTaxonomyTermTranslationTest.php index 3e32406d2..1baf584ef 100644 --- a/web/core/modules/content_translation/tests/src/Kernel/Migrate/d6/MigrateTaxonomyTermTranslationTest.php +++ b/web/core/modules/content_translation/tests/src/Kernel/Migrate/d6/MigrateTaxonomyTermTranslationTest.php @@ -108,7 +108,12 @@ class MigrateTaxonomyTermTranslationTest extends MigrateDrupal6TestBase { $this->assertArrayHasKey($tid, $this->treeData[$vid], "Term $tid exists in taxonomy tree"); $term = $this->treeData[$vid][$tid]; - $this->assertEquals($parent_ids, array_filter($term->parents), "Term $tid has correct parents in taxonomy tree"); + // PostgreSQL, MySQL and SQLite may not return the parent terms in the same + // order so sort before testing. + sort($parent_ids); + $actual_terms = array_filter($term->parents); + sort($actual_terms); + $this->assertEquals($parent_ids, $actual_terms, "Term $tid has correct parents in taxonomy tree"); } /** diff --git a/web/core/modules/field/src/Tests/EntityReference/EntityReferenceAdminTest.php b/web/core/modules/field/tests/src/Functional/EntityReference/EntityReferenceAdminTest.php similarity index 64% rename from web/core/modules/field/src/Tests/EntityReference/EntityReferenceAdminTest.php rename to web/core/modules/field/tests/src/Functional/EntityReference/EntityReferenceAdminTest.php index 7a5e3ea4c..8510539c5 100644 --- a/web/core/modules/field/src/Tests/EntityReference/EntityReferenceAdminTest.php +++ b/web/core/modules/field/tests/src/Functional/EntityReference/EntityReferenceAdminTest.php @@ -1,20 +1,21 @@ type; - - // First step: 'Add new field' on the 'Manage fields' page. - $this->drupalGet($bundle_path . '/fields/add-field'); - - // Check if the commonly referenced entity types appear in the list. - $this->assertOption('edit-new-storage-type', 'field_ui:entity_reference:node'); - $this->assertOption('edit-new-storage-type', 'field_ui:entity_reference:user'); - - $this->drupalPostForm(NULL, [ - 'label' => 'Test label', - 'field_name' => 'test', - 'new_storage_type' => 'entity_reference', - ], t('Save and continue')); - - // Node should be selected by default. - $this->assertFieldByName('settings[target_type]', 'node'); - - // Check that all entity types can be referenced. - $this->assertFieldSelectOptions('settings[target_type]', array_keys(\Drupal::entityManager()->getDefinitions())); - - // Second step: 'Field settings' form. - $this->drupalPostForm(NULL, [], t('Save field settings')); - - // The base handler should be selected by default. - $this->assertFieldByName('settings[handler]', 'default:node'); - - // The base handler settings should be displayed. - $entity_type_id = 'node'; - // Check that the type label is correctly displayed. - $this->assertText('Content type'); - $bundles = $this->container->get('entity_type.bundle.info')->getBundleInfo($entity_type_id); - foreach ($bundles as $bundle_name => $bundle_info) { - $this->assertFieldByName('settings[handler_settings][target_bundles][' . $bundle_name . ']'); - } - - reset($bundles); - - // Test the sort settings. - // Option 0: no sort. - $this->assertFieldByName('settings[handler_settings][sort][field]', '_none'); - $this->assertNoFieldByName('settings[handler_settings][sort][direction]'); - // Option 1: sort by field. - $this->drupalPostAjaxForm(NULL, ['settings[handler_settings][sort][field]' => 'nid'], 'settings[handler_settings][sort][field]'); - $this->assertFieldByName('settings[handler_settings][sort][direction]', 'ASC'); - - // Test that a non-translatable base field is a sort option. - $this->assertFieldByXPath("//select[@name='settings[handler_settings][sort][field]']/option[@value='nid']"); - // Test that a translatable base field is a sort option. - $this->assertFieldByXPath("//select[@name='settings[handler_settings][sort][field]']/option[@value='title']"); - // Test that a configurable field is a sort option. - $this->assertFieldByXPath("//select[@name='settings[handler_settings][sort][field]']/option[@value='body.value']"); - - // Set back to no sort. - $this->drupalPostAjaxForm(NULL, ['settings[handler_settings][sort][field]' => '_none'], 'settings[handler_settings][sort][field]'); - $this->assertNoFieldByName('settings[handler_settings][sort][direction]'); - - // Third step: confirm. - $this->drupalPostForm(NULL, [ - 'required' => '1', - 'settings[handler_settings][target_bundles][' . key($bundles) . ']' => key($bundles), - ], t('Save settings')); - - // Check that the field appears in the overview form. - $this->assertFieldByXPath('//table[@id="field-overview"]//tr[@id="field-test"]/td[1]', 'Test label', 'Field was created and appears in the overview page.'); - - // Check that the field settings form can be submitted again, even when the - // field is required. - // The first 'Edit' link is for the Body field. - $this->clickLink(t('Edit'), 1); - $this->drupalPostForm(NULL, [], t('Save settings')); - - // Switch the target type to 'taxonomy_term' and check that the settings - // specific to its selection handler are displayed. - $field_name = 'node.' . $this->type . '.field_test'; - $edit = [ - 'settings[target_type]' => 'taxonomy_term', - ]; - $this->drupalPostForm($bundle_path . '/fields/' . $field_name . '/storage', $edit, t('Save field settings')); - $this->drupalGet($bundle_path . '/fields/' . $field_name); - $this->assertFieldByName('settings[handler_settings][auto_create]'); - - // Switch the target type to 'user' and check that the settings specific to - // its selection handler are displayed. - $field_name = 'node.' . $this->type . '.field_test'; - $edit = [ - 'settings[target_type]' => 'user', - ]; - $this->drupalPostForm($bundle_path . '/fields/' . $field_name . '/storage', $edit, t('Save field settings')); - $this->drupalGet($bundle_path . '/fields/' . $field_name); - $this->assertFieldByName('settings[handler_settings][filter][type]', '_none'); - - // Switch the target type to 'node'. - $field_name = 'node.' . $this->type . '.field_test'; - $edit = [ - 'settings[target_type]' => 'node', - ]; - $this->drupalPostForm($bundle_path . '/fields/' . $field_name . '/storage', $edit, t('Save field settings')); - - // Try to select the views handler. - $edit = [ - 'settings[handler]' => 'views', - ]; - $this->drupalPostAjaxForm($bundle_path . '/fields/' . $field_name, $edit, 'settings[handler]'); - $this->assertRaw(t('No eligible views were found. Create a view with an Entity Reference display, or add such a display to an existing view.', [ - ':create' => \Drupal::url('views_ui.add'), - ':existing' => \Drupal::url('entity.view.collection'), - ])); - $this->drupalPostForm(NULL, $edit, t('Save settings')); - // If no eligible view is available we should see a message. - $this->assertText('The views entity selection mode requires a view.'); - - // Enable the entity_reference_test module which creates an eligible view. - $this->container->get('module_installer')->install(['entity_reference_test']); - $this->resetAll(); - $this->drupalGet($bundle_path . '/fields/' . $field_name); - $this->drupalPostAjaxForm($bundle_path . '/fields/' . $field_name, $edit, 'settings[handler]'); - $edit = [ - 'settings[handler_settings][view][view_and_display]' => 'test_entity_reference:entity_reference_1', - ]; - $this->drupalPostForm(NULL, $edit, t('Save settings')); - $this->assertResponse(200); - - // Switch the target type to 'entity_test'. - $edit = [ - 'settings[target_type]' => 'entity_test', - ]; - $this->drupalPostForm($bundle_path . '/fields/' . $field_name . '/storage', $edit, t('Save field settings')); - $this->drupalGet($bundle_path . '/fields/' . $field_name); - $edit = [ - 'settings[handler]' => 'views', - ]; - $this->drupalPostAjaxForm($bundle_path . '/fields/' . $field_name, $edit, 'settings[handler]'); - $edit = [ - 'required' => FALSE, - 'settings[handler_settings][view][view_and_display]' => 'test_entity_reference_entity_test:entity_reference_1', - ]; - $this->drupalPostForm(NULL, $edit, t('Save settings')); - $this->assertResponse(200); - // Create a new view and display it as a entity reference. $edit = [ 'id' => 'node_test_view', @@ -253,7 +115,7 @@ class EntityReferenceAdminTest extends WebTestBase { $edit = [ 'settings[handler]' => 'views', ]; - $this->drupalPostAjaxForm(NULL, $edit, 'settings[handler]'); + $this->drupalPostForm(NULL, $edit, t('Change handler')); $edit = [ 'required' => FALSE, 'settings[handler_settings][view][view_and_display]' => 'node_test_view:entity_reference_1', @@ -275,7 +137,7 @@ class EntityReferenceAdminTest extends WebTestBase { // Try to add a new node and fill the entity reference field. $this->drupalGet('node/add/' . $this->type); $result = $this->xpath('//input[@name="field_test_entity_ref_field[0][target_id]" and contains(@data-autocomplete-path, "/entity_reference_autocomplete/node/views/")]'); - $target_url = $this->getAbsoluteUrl($result[0]['data-autocomplete-path']); + $target_url = $this->getAbsoluteUrl($result[0]->getAttribute('data-autocomplete-path')); $this->drupalGet($target_url, ['query' => ['q' => 'Foo']]); $this->assertRaw($node1->getTitle() . ' (' . $node1->id() . ')'); $this->assertRaw($node2->getTitle() . ' (' . $node2->id() . ')'); @@ -446,7 +308,8 @@ class EntityReferenceAdminTest extends WebTestBase { 'settings[handler_settings][target_bundles][' . $vocabularies[1]->id() . ']' => TRUE, ]; // Enable the second vocabulary as a target bundle. - $this->drupalPostAjaxForm($path, $edit, key($edit)); + $this->drupalPostForm($path, $edit, 'Save settings'); + $this->drupalGet($path); // Expect a select element with the two vocabularies as options. $this->assertFieldByXPath("//select[@name='settings[handler_settings][auto_create_bundle]']/option[@value='" . $vocabularies[0]->id() . "']"); $this->assertFieldByXPath("//select[@name='settings[handler_settings][auto_create_bundle]']/option[@value='" . $vocabularies[1]->id() . "']"); @@ -513,49 +376,23 @@ class EntityReferenceAdminTest extends WebTestBase { * The field name. * @param array $expected_options * An array of expected options. - * - * @return bool - * TRUE if the assertion succeeded, FALSE otherwise. */ protected function assertFieldSelectOptions($name, array $expected_options) { $xpath = $this->buildXPathQuery('//select[@name=:name]', [':name' => $name]); $fields = $this->xpath($xpath); if ($fields) { $field = $fields[0]; - $options = $this->getAllOptionsList($field); - + $options = $field->findAll('xpath', 'option'); + array_walk($options, function (NodeElement &$option) { + $option = $option->getValue(); + }); sort($options); sort($expected_options); - - return $this->assertIdentical($options, $expected_options); + $this->assertIdentical($options, $expected_options); } else { - return $this->fail('Unable to find field ' . $name); + $this->fail('Unable to find field ' . $name); } } - /** - * Extracts all options from a select element. - * - * @param \SimpleXMLElement $element - * The select element field information. - * - * @return array - * An array of option values as strings. - */ - protected function getAllOptionsList(\SimpleXMLElement $element) { - $options = []; - // Add all options items. - foreach ($element->option as $option) { - $options[] = (string) $option['value']; - } - - // Loops trough all the option groups - foreach ($element->optgroup as $optgroup) { - $options = array_merge($this->getAllOptionsList($optgroup), $options); - } - - return $options; - } - } diff --git a/web/core/modules/field/tests/src/FunctionalJavascript/EntityReference/EntityReferenceAdminTest.php b/web/core/modules/field/tests/src/FunctionalJavascript/EntityReference/EntityReferenceAdminTest.php new file mode 100644 index 000000000..89a8ce1e4 --- /dev/null +++ b/web/core/modules/field/tests/src/FunctionalJavascript/EntityReference/EntityReferenceAdminTest.php @@ -0,0 +1,244 @@ +drupalPlaceBlock('system_breadcrumb_block'); + + // Create a content type, with underscores. + $type_name = strtolower($this->randomMachineName(8)) . '_test'; + $type = $this->drupalCreateContentType(['name' => $type_name, 'type' => $type_name]); + $this->type = $type->id(); + + // Create test user. + $admin_user = $this->drupalCreateUser([ + 'access content', + 'administer node fields', + 'administer node display', + 'administer views', + 'create ' . $type_name . ' content', + 'edit own ' . $type_name . ' content', + ]); + $this->drupalLogin($admin_user); + } + + /** + * Tests the Entity Reference Admin UI. + */ + public function testFieldAdminHandler() { + $bundle_path = 'admin/structure/types/manage/' . $this->type; + + $page = $this->getSession()->getPage(); + $assert_session = $this->assertSession(); + + // First step: 'Add new field' on the 'Manage fields' page. + $this->drupalGet($bundle_path . '/fields/add-field'); + + // Check if the commonly referenced entity types appear in the list. + $this->assertOption('edit-new-storage-type', 'field_ui:entity_reference:node'); + $this->assertOption('edit-new-storage-type', 'field_ui:entity_reference:user'); + + $page->findField('new_storage_type')->setValue('entity_reference'); + $assert_session->waitForField('label')->setValue('Test'); + $machine_name = $assert_session->waitForElement('xpath', '//*[@id="edit-label-machine-name-suffix"]/span[2]/span[contains(text(), "field_test")]'); + $this->assertNotEmpty($machine_name); + $page->pressButton('Save and continue'); + + // Node should be selected by default. + $this->assertFieldByName('settings[target_type]', 'node'); + + // Check that all entity types can be referenced. + $this->assertFieldSelectOptions('settings[target_type]', array_keys(\Drupal::entityManager()->getDefinitions())); + + // Second step: 'Field settings' form. + $this->drupalPostForm(NULL, [], t('Save field settings')); + + // The base handler should be selected by default. + $this->assertFieldByName('settings[handler]', 'default:node'); + + // The base handler settings should be displayed. + $entity_type_id = 'node'; + // Check that the type label is correctly displayed. + $assert_session->pageTextContains('Content type'); + $bundles = $this->container->get('entity_type.bundle.info')->getBundleInfo($entity_type_id); + foreach ($bundles as $bundle_name => $bundle_info) { + $this->assertFieldByName('settings[handler_settings][target_bundles][' . $bundle_name . ']'); + } + + reset($bundles); + + // Test the sort settings. + // Option 0: no sort. + $this->assertFieldByName('settings[handler_settings][sort][field]', '_none'); + $this->assertNoFieldByName('settings[handler_settings][sort][direction]'); + // Option 1: sort by field. + $page->findField('settings[handler_settings][sort][field]')->setValue('nid'); + $assert_session->waitForField('settings[handler_settings][sort][direction]'); + $this->assertFieldByName('settings[handler_settings][sort][direction]', 'ASC'); + + // Test that a non-translatable base field is a sort option. + $this->assertFieldByXPath("//select[@name='settings[handler_settings][sort][field]']/option[@value='nid']"); + // Test that a translatable base field is a sort option. + $this->assertFieldByXPath("//select[@name='settings[handler_settings][sort][field]']/option[@value='title']"); + // Test that a configurable field is a sort option. + $this->assertFieldByXPath("//select[@name='settings[handler_settings][sort][field]']/option[@value='body.value']"); + + // Set back to no sort. + $page->findField('settings[handler_settings][sort][field]')->setValue('_none'); + $assert_session->assertWaitOnAjaxRequest(); + $this->assertNoFieldByName('settings[handler_settings][sort][direction]'); + + // Third step: confirm. + $page->findField('settings[handler_settings][target_bundles][' . key($bundles) . ']')->setValue(key($bundles)); + $assert_session->assertWaitOnAjaxRequest(); + $this->drupalPostForm(NULL, [ + 'required' => '1', + ], t('Save settings')); + + // Check that the field appears in the overview form. + $this->assertFieldByXPath('//table[@id="field-overview"]//tr[@id="field-test"]/td[1]', 'Test', 'Field was created and appears in the overview page.'); + + // Check that the field settings form can be submitted again, even when the + // field is required. + // The first 'Edit' link is for the Body field. + $this->clickLink(t('Edit'), 1); + $this->drupalPostForm(NULL, [], t('Save settings')); + + // Switch the target type to 'taxonomy_term' and check that the settings + // specific to its selection handler are displayed. + $field_name = 'node.' . $this->type . '.field_test'; + $edit = [ + 'settings[target_type]' => 'taxonomy_term', + ]; + $this->drupalPostForm($bundle_path . '/fields/' . $field_name . '/storage', $edit, t('Save field settings')); + $this->drupalGet($bundle_path . '/fields/' . $field_name); + $this->assertFieldByName('settings[handler_settings][auto_create]'); + + // Switch the target type to 'user' and check that the settings specific to + // its selection handler are displayed. + $field_name = 'node.' . $this->type . '.field_test'; + $edit = [ + 'settings[target_type]' => 'user', + ]; + $this->drupalPostForm($bundle_path . '/fields/' . $field_name . '/storage', $edit, t('Save field settings')); + $this->drupalGet($bundle_path . '/fields/' . $field_name); + $this->assertFieldByName('settings[handler_settings][filter][type]', '_none'); + + // Switch the target type to 'node'. + $field_name = 'node.' . $this->type . '.field_test'; + $edit = [ + 'settings[target_type]' => 'node', + ]; + $this->drupalPostForm($bundle_path . '/fields/' . $field_name . '/storage', $edit, t('Save field settings')); + + // Try to select the views handler. + $this->drupalGet($bundle_path . '/fields/' . $field_name); + $page->findField('settings[handler]')->setValue('views'); + $views_text = (string) new FormattableMarkup('No eligible views were found. Create a view with an Entity Reference display, or add such a display to an existing view.', [ + ':create' => \Drupal::url('views_ui.add'), + ':existing' => \Drupal::url('entity.view.collection'), + ]); + $assert_session->waitForElement('xpath', '//a[contains(text(), "Create a view")]'); + $assert_session->responseContains($views_text); + + $this->drupalPostForm(NULL, [], t('Save settings')); + // If no eligible view is available we should see a message. + $assert_session->pageTextContains('The views entity selection mode requires a view.'); + + // Enable the entity_reference_test module which creates an eligible view. + $this->container->get('module_installer') + ->install(['entity_reference_test']); + $this->resetAll(); + $this->drupalGet($bundle_path . '/fields/' . $field_name); + $page->findField('settings[handler]')->setValue('views'); + $assert_session + ->waitForField('settings[handler_settings][view][view_and_display]') + ->setValue('test_entity_reference:entity_reference_1'); + $this->drupalPostForm(NULL, [], t('Save settings')); + $assert_session->pageTextContains('Saved Test configuration.'); + + // Switch the target type to 'entity_test'. + $edit = [ + 'settings[target_type]' => 'entity_test', + ]; + $this->drupalPostForm($bundle_path . '/fields/' . $field_name . '/storage', $edit, t('Save field settings')); + $this->drupalGet($bundle_path . '/fields/' . $field_name); + $page->findField('settings[handler]')->setValue('views'); + $assert_session + ->waitForField('settings[handler_settings][view][view_and_display]') + ->setValue('test_entity_reference_entity_test:entity_reference_1'); + $edit = [ + 'required' => FALSE, + ]; + $this->drupalPostForm(NULL, $edit, t('Save settings')); + $assert_session->pageTextContains('Saved Test configuration.'); + } + + /** + * Checks if a select element contains the specified options. + * + * @param string $name + * The field name. + * @param array $expected_options + * An array of expected options. + */ + protected function assertFieldSelectOptions($name, array $expected_options) { + $xpath = $this->buildXPathQuery('//select[@name=:name]', [':name' => $name]); + $fields = $this->xpath($xpath); + if ($fields) { + $field = $fields[0]; + $options = $field->findAll('xpath', 'option'); + $optgroups = $field->findAll('xpath', 'optgroup'); + foreach ($optgroups as $optgroup) { + $options = array_merge($options, $optgroup->findAll('xpath', 'option')); + } + array_walk($options, function (NodeElement &$option) { + $option = $option->getAttribute('value'); + }); + + sort($options); + sort($expected_options); + + $this->assertIdentical($options, $expected_options); + } + else { + $this->fail('Unable to find field ' . $name); + } + } + +} diff --git a/web/core/modules/field/tests/src/Kernel/String/StringFormatterTest.php b/web/core/modules/field/tests/src/Kernel/String/StringFormatterTest.php index ae3a371c8..74f16d582 100644 --- a/web/core/modules/field/tests/src/Kernel/String/StringFormatterTest.php +++ b/web/core/modules/field/tests/src/Kernel/String/StringFormatterTest.php @@ -24,6 +24,13 @@ class StringFormatterTest extends KernelTestBase { */ public static $modules = ['field', 'text', 'entity_test', 'system', 'filter', 'user']; + /** + * The entity type manager. + * + * @var \Drupal\Core\Entity\EntityTypeManagerInterface + */ + protected $entityTypeManager; + /** * @var string */ @@ -79,6 +86,8 @@ class StringFormatterTest extends KernelTestBase { 'settings' => [], ]); $this->display->save(); + + $this->entityTypeManager = \Drupal::entityTypeManager(); } /** @@ -145,7 +154,7 @@ class StringFormatterTest extends KernelTestBase { $value2 = $this->randomMachineName(); $entity->{$this->fieldName}->value = $value2; $entity->save(); - $entity_new_revision = \Drupal::entityManager()->getStorage('entity_test_rev')->loadRevision($old_revision_id); + $entity_new_revision = $this->entityTypeManager->getStorage('entity_test_rev')->loadRevision($old_revision_id); $this->renderEntityFields($entity, $this->display); $this->assertLink($value2, 0); @@ -154,6 +163,19 @@ class StringFormatterTest extends KernelTestBase { $this->renderEntityFields($entity_new_revision, $this->display); $this->assertLink($value, 0); $this->assertLinkByHref('/entity_test_rev/' . $entity_new_revision->id() . '/revision/' . $entity_new_revision->getRevisionId() . '/view'); + + // Check that linking to a revisionable entity works if the entity type does + // not specify a 'revision' link template. + $entity_type = clone $this->entityTypeManager->getDefinition('entity_test_rev'); + $link_templates = $entity_type->getLinkTemplates(); + unset($link_templates['revision']); + $entity_type->set('links', $link_templates); + \Drupal::state()->set('entity_test_rev.entity_type', $entity_type); + $this->entityTypeManager->clearCachedDefinitions(); + + $this->renderEntityFields($entity_new_revision, $this->display); + $this->assertLink($value, 0); + $this->assertLinkByHref($entity->url('canonical')); } } diff --git a/web/core/modules/field_ui/tests/src/Functional/ManageFieldsFunctionalTest.php b/web/core/modules/field_ui/tests/src/Functional/ManageFieldsFunctionalTest.php index 3e31c815e..85a5017cb 100644 --- a/web/core/modules/field_ui/tests/src/Functional/ManageFieldsFunctionalTest.php +++ b/web/core/modules/field_ui/tests/src/Functional/ManageFieldsFunctionalTest.php @@ -176,7 +176,7 @@ class ManageFieldsFunctionalTest extends BrowserTestBase { /** * Tests adding a new field. * - * @todo Assert properties can bet set in the form and read back in + * @todo Assert properties can be set in the form and read back in * $field_storage and $fields. */ public function createField() { diff --git a/web/core/modules/file/file.module b/web/core/modules/file/file.module index c0003b350..a6f68094a 100644 --- a/web/core/modules/file/file.module +++ b/web/core/modules/file/file.module @@ -859,7 +859,6 @@ function _file_save_upload_from_form(array $element, FormStateInterface $form_st * @todo: move this logic to a service in https://www.drupal.org/node/2244513. */ function file_save_upload($form_field_name, $validators = [], $destination = FALSE, $delta = NULL, $replace = FILE_EXISTS_RENAME) { - $user = \Drupal::currentUser(); static $upload_cache; $all_files = \Drupal::request()->files->get('files', []); @@ -887,182 +886,208 @@ function file_save_upload($form_field_name, $validators = [], $destination = FAL $files = []; foreach ($uploaded_files as $i => $file_info) { - // Check for file upload errors and return FALSE for this file if a lower - // level system error occurred. For a complete list of errors: - // See http://php.net/manual/features.file-upload.errors.php. - switch ($file_info->getError()) { - case UPLOAD_ERR_INI_SIZE: - case UPLOAD_ERR_FORM_SIZE: - \Drupal::messenger()->addError(t('The file %file could not be saved because it exceeds %maxsize, the maximum allowed size for uploads.', ['%file' => $file_info->getFilename(), '%maxsize' => format_size(file_upload_max_size())])); - $files[$i] = FALSE; - continue; - - case UPLOAD_ERR_PARTIAL: - case UPLOAD_ERR_NO_FILE: - \Drupal::messenger()->addError(t('The file %file could not be saved because the upload did not complete.', ['%file' => $file_info->getFilename()])); - $files[$i] = FALSE; - continue; - - case UPLOAD_ERR_OK: - // Final check that this is a valid upload, if it isn't, use the - // default error handler. - if (is_uploaded_file($file_info->getRealPath())) { - break; - } + $files[$i] = _file_save_upload_single($file_info, $form_field_name, $validators, $destination, $replace); + } - // Unknown error - default: - \Drupal::messenger()->addError(t('The file %file could not be saved. An unknown error has occurred.', ['%file' => $file_info->getFilename()])); - $files[$i] = FALSE; - continue; + // Add files to the cache. + $upload_cache[$form_field_name] = $files; - } - // Begin building file entity. - $values = [ - 'uid' => $user->id(), - 'status' => 0, - 'filename' => $file_info->getClientOriginalName(), - 'uri' => $file_info->getRealPath(), - 'filesize' => $file_info->getSize(), - ]; - $values['filemime'] = \Drupal::service('file.mime_type.guesser')->guess($values['filename']); - $file = File::create($values); - - $extensions = ''; - if (isset($validators['file_validate_extensions'])) { - if (isset($validators['file_validate_extensions'][0])) { - // Build the list of non-munged extensions if the caller provided them. - $extensions = $validators['file_validate_extensions'][0]; - } - else { - // If 'file_validate_extensions' is set and the list is empty then the - // caller wants to allow any extension. In this case we have to remove the - // validator or else it will reject all extensions. - unset($validators['file_validate_extensions']); - } - } - else { - // No validator was provided, so add one using the default list. - // Build a default non-munged safe list for file_munge_filename(). - $extensions = 'jpg jpeg gif png txt doc xls pdf ppt pps odt ods odp'; - $validators['file_validate_extensions'] = []; - $validators['file_validate_extensions'][0] = $extensions; - } + return isset($delta) ? $files[$delta] : $files; +} - if (!empty($extensions)) { - // Munge the filename to protect against possible malicious extension - // hiding within an unknown file type (ie: filename.html.foo). - $file->setFilename(file_munge_filename($file->getFilename(), $extensions)); - } +/** + * Saves a file upload to a new location. + * + * @param \SplFileInfo $file_info + * The file upload to save. + * @param string $form_field_name + * A string that is the associative array key of the upload form element in + * the form array. + * @param array $validators + * (optional) An associative array of callback functions used to validate the + * file. + * @param bool $destination + * (optional) A string containing the URI that the file should be copied to. + * @param int $replace + * (optional) The replace behavior when the destination file already exists. + * + * @return \Drupal\file\FileInterface|false + * The created file entity or FALSE if the uploaded file not saved. + * + * @throws \Drupal\Core\Entity\EntityStorageException + * + * @internal + * This method should only be called from file_save_upload(). Use that method + * instead. + * + * @see file_save_upload() + */ +function _file_save_upload_single(\SplFileInfo $file_info, $form_field_name, $validators = [], $destination = FALSE, $replace = FILE_EXISTS_RENAME) { + $user = \Drupal::currentUser(); + // Check for file upload errors and return FALSE for this file if a lower + // level system error occurred. For a complete list of errors: + // See http://php.net/manual/features.file-upload.errors.php. + switch ($file_info->getError()) { + case UPLOAD_ERR_INI_SIZE: + case UPLOAD_ERR_FORM_SIZE: + \Drupal::messenger()->addError(t('The file %file could not be saved because it exceeds %maxsize, the maximum allowed size for uploads.', ['%file' => $file_info->getFilename(), '%maxsize' => format_size(file_upload_max_size())])); + return FALSE; + + case UPLOAD_ERR_PARTIAL: + case UPLOAD_ERR_NO_FILE: + \Drupal::messenger()->addError(t('The file %file could not be saved because the upload did not complete.', ['%file' => $file_info->getFilename()])); + return FALSE; - // Rename potentially executable files, to help prevent exploits (i.e. will - // rename filename.php.foo and filename.php to filename.php.foo.txt and - // filename.php.txt, respectively). Don't rename if 'allow_insecure_uploads' - // evaluates to TRUE. - if (!\Drupal::config('system.file')->get('allow_insecure_uploads') && preg_match(FILE_INSECURE_EXTENSION_REGEX, $file->getFilename()) && (substr($file->getFilename(), -4) != '.txt')) { - $file->setMimeType('text/plain'); - // The destination filename will also later be used to create the URI. - $file->setFilename($file->getFilename() . '.txt'); - // The .txt extension may not be in the allowed list of extensions. We have - // to add it here or else the file upload will fail. - if (!empty($extensions)) { - $validators['file_validate_extensions'][0] .= ' txt'; - \Drupal::messenger()->addStatus(t('For security reasons, your upload has been renamed to %filename.', ['%filename' => $file->getFilename()])); + case UPLOAD_ERR_OK: + // Final check that this is a valid upload, if it isn't, use the + // default error handler. + if (is_uploaded_file($file_info->getRealPath())) { + break; } - } - // If the destination is not provided, use the temporary directory. - if (empty($destination)) { - $destination = 'temporary://'; - } + default: + // Unknown error + \Drupal::messenger()->addError(t('The file %file could not be saved. An unknown error has occurred.', ['%file' => $file_info->getFilename()])); + return FALSE; - // Assert that the destination contains a valid stream. - $destination_scheme = file_uri_scheme($destination); - if (!file_stream_wrapper_valid_scheme($destination_scheme)) { - \Drupal::messenger()->addError(t('The file could not be uploaded because the destination %destination is invalid.', ['%destination' => $destination])); - $files[$i] = FALSE; - continue; + } + // Begin building file entity. + $values = [ + 'uid' => $user->id(), + 'status' => 0, + 'filename' => $file_info->getClientOriginalName(), + 'uri' => $file_info->getRealPath(), + 'filesize' => $file_info->getSize(), + ]; + $values['filemime'] = \Drupal::service('file.mime_type.guesser')->guess($values['filename']); + $file = File::create($values); + + $extensions = ''; + if (isset($validators['file_validate_extensions'])) { + if (isset($validators['file_validate_extensions'][0])) { + // Build the list of non-munged extensions if the caller provided them. + $extensions = $validators['file_validate_extensions'][0]; } - - $file->source = $form_field_name; - // A file URI may already have a trailing slash or look like "public://". - if (substr($destination, -1) != '/') { - $destination .= '/'; + else { + // If 'file_validate_extensions' is set and the list is empty then the + // caller wants to allow any extension. In this case we have to remove the + // validator or else it will reject all extensions. + unset($validators['file_validate_extensions']); } - $file->destination = file_destination($destination . $file->getFilename(), $replace); - // If file_destination() returns FALSE then $replace === FILE_EXISTS_ERROR and - // there's an existing file so we need to bail. - if ($file->destination === FALSE) { - \Drupal::messenger()->addError(t('The file %source could not be uploaded because a file by that name already exists in the destination %directory.', ['%source' => $form_field_name, '%directory' => $destination])); - $files[$i] = FALSE; - continue; + } + else { + // No validator was provided, so add one using the default list. + // Build a default non-munged safe list for file_munge_filename(). + $extensions = 'jpg jpeg gif png txt doc xls pdf ppt pps odt ods odp'; + $validators['file_validate_extensions'] = []; + $validators['file_validate_extensions'][0] = $extensions; + } + + if (!empty($extensions)) { + // Munge the filename to protect against possible malicious extension + // hiding within an unknown file type (ie: filename.html.foo). + $file->setFilename(file_munge_filename($file->getFilename(), $extensions)); + } + + // Rename potentially executable files, to help prevent exploits (i.e. will + // rename filename.php.foo and filename.php to filename.php.foo.txt and + // filename.php.txt, respectively). Don't rename if 'allow_insecure_uploads' + // evaluates to TRUE. + if (!\Drupal::config('system.file')->get('allow_insecure_uploads') && preg_match(FILE_INSECURE_EXTENSION_REGEX, $file->getFilename()) && (substr($file->getFilename(), -4) != '.txt')) { + $file->setMimeType('text/plain'); + // The destination filename will also later be used to create the URI. + $file->setFilename($file->getFilename() . '.txt'); + // The .txt extension may not be in the allowed list of extensions. We have + // to add it here or else the file upload will fail. + if (!empty($extensions)) { + $validators['file_validate_extensions'][0] .= ' txt'; + \Drupal::messenger()->addStatus(t('For security reasons, your upload has been renamed to %filename.', ['%filename' => $file->getFilename()])); } + } - // Add in our check of the file name length. - $validators['file_validate_name_length'] = []; + // If the destination is not provided, use the temporary directory. + if (empty($destination)) { + $destination = 'temporary://'; + } - // Call the validation functions specified by this function's caller. - $errors = file_validate($file, $validators); + // Assert that the destination contains a valid stream. + $destination_scheme = file_uri_scheme($destination); + if (!file_stream_wrapper_valid_scheme($destination_scheme)) { + \Drupal::messenger()->addError(t('The file could not be uploaded because the destination %destination is invalid.', ['%destination' => $destination])); + return FALSE; + } - // Check for errors. - if (!empty($errors)) { - $message = [ - 'error' => [ - '#markup' => t('The specified file %name could not be uploaded.', ['%name' => $file->getFilename()]), - ], - 'item_list' => [ - '#theme' => 'item_list', - '#items' => $errors, - ], - ]; - // @todo Add support for render arrays in - // \Drupal\Core\Messenger\MessengerInterface::addMessage()? - // @see https://www.drupal.org/node/2505497. - \Drupal::messenger()->addError(\Drupal::service('renderer')->renderPlain($message)); - $files[$i] = FALSE; - continue; - } + $file->source = $form_field_name; + // A file URI may already have a trailing slash or look like "public://". + if (substr($destination, -1) != '/') { + $destination .= '/'; + } + $file->destination = file_destination($destination . $file->getFilename(), $replace); + // If file_destination() returns FALSE then $replace === FILE_EXISTS_ERROR and + // there's an existing file so we need to bail. + if ($file->destination === FALSE) { + \Drupal::messenger()->addError(t('The file %source could not be uploaded because a file by that name already exists in the destination %directory.', ['%source' => $form_field_name, '%directory' => $destination])); + return FALSE; + } - $file->setFileUri($file->destination); - if (!drupal_move_uploaded_file($file_info->getRealPath(), $file->getFileUri())) { - \Drupal::messenger()->addError(t('File upload error. Could not move uploaded file.')); - \Drupal::logger('file')->notice('Upload error. Could not move uploaded file %file to destination %destination.', ['%file' => $file->getFilename(), '%destination' => $file->getFileUri()]); - $files[$i] = FALSE; - continue; - } + // Add in our check of the file name length. + $validators['file_validate_name_length'] = []; - // Set the permissions on the new file. - drupal_chmod($file->getFileUri()); + // Call the validation functions specified by this function's caller. + $errors = file_validate($file, $validators); + + // Check for errors. + if (!empty($errors)) { + $message = [ + 'error' => [ + '#markup' => t('The specified file %name could not be uploaded.', ['%name' => $file->getFilename()]), + ], + 'item_list' => [ + '#theme' => 'item_list', + '#items' => $errors, + ], + ]; + // @todo Add support for render arrays in + // \Drupal\Core\Messenger\MessengerInterface::addMessage()? + // @see https://www.drupal.org/node/2505497. + \Drupal::messenger()->addError(\Drupal::service('renderer')->renderPlain($message)); + return FALSE; + } - // If we are replacing an existing file re-use its database record. - // @todo Do not create a new entity in order to update it. See - // https://www.drupal.org/node/2241865. - if ($replace == FILE_EXISTS_REPLACE) { - $existing_files = entity_load_multiple_by_properties('file', ['uri' => $file->getFileUri()]); - if (count($existing_files)) { - $existing = reset($existing_files); - $file->fid = $existing->id(); - $file->setOriginalId($existing->id()); - } - } + $file->setFileUri($file->destination); + if (!drupal_move_uploaded_file($file_info->getRealPath(), $file->getFileUri())) { + \Drupal::messenger()->addError(t('File upload error. Could not move uploaded file.')); + \Drupal::logger('file')->notice('Upload error. Could not move uploaded file %file to destination %destination.', ['%file' => $file->getFilename(), '%destination' => $file->getFileUri()]); + return FALSE; + } - // If we made it this far it's safe to record this file in the database. - $file->save(); - $files[$i] = $file; - // Allow an anonymous user who creates a non-public file to see it. See - // \Drupal\file\FileAccessControlHandler::checkAccess(). - if ($user->isAnonymous() && $destination_scheme !== 'public') { - $session = \Drupal::request()->getSession(); - $allowed_temp_files = $session->get('anonymous_allowed_file_ids', []); - $allowed_temp_files[$file->id()] = $file->id(); - $session->set('anonymous_allowed_file_ids', $allowed_temp_files); + // Set the permissions on the new file. + drupal_chmod($file->getFileUri()); + + // If we are replacing an existing file re-use its database record. + // @todo Do not create a new entity in order to update it. See + // https://www.drupal.org/node/2241865. + if ($replace == FILE_EXISTS_REPLACE) { + $existing_files = entity_load_multiple_by_properties('file', ['uri' => $file->getFileUri()]); + if (count($existing_files)) { + $existing = reset($existing_files); + $file->fid = $existing->id(); + $file->setOriginalId($existing->id()); } } - // Add files to the cache. - $upload_cache[$form_field_name] = $files; + // If we made it this far it's safe to record this file in the database. + $file->save(); - return isset($delta) ? $files[$delta] : $files; + // Allow an anonymous user who creates a non-public file to see it. See + // \Drupal\file\FileAccessControlHandler::checkAccess(). + if ($user->isAnonymous() && $destination_scheme !== 'public') { + $session = \Drupal::request()->getSession(); + $allowed_temp_files = $session->get('anonymous_allowed_file_ids', []); + $allowed_temp_files[$file->id()] = $file->id(); + $session->set('anonymous_allowed_file_ids', $allowed_temp_files); + } + return $file; } /** diff --git a/web/core/modules/file/tests/src/Functional/MultipleFileUploadTest.php b/web/core/modules/file/tests/src/Functional/MultipleFileUploadTest.php new file mode 100644 index 000000000..587d4479e --- /dev/null +++ b/web/core/modules/file/tests/src/Functional/MultipleFileUploadTest.php @@ -0,0 +1,59 @@ +drupalCreateUser(['administer themes']); + $this->drupalLogin($admin); + } + + /** + * Tests multiple file field with all file extensions. + */ + public function testMultipleFileFieldWithAllFileExtensions() { + $theme = 'test_theme_settings'; + \Drupal::service('theme_handler')->install([$theme]); + $this->drupalGet("admin/appearance/settings/$theme"); + + $edit = []; + // Create few files with non-typical extensions. + foreach (['file1.wtf', 'file2.wtf'] as $i => $file) { + $file_path = $this->root . "/sites/default/files/simpletest/$file"; + file_put_contents($file_path, 'File with non-default extension.', FILE_APPEND | LOCK_EX); + $edit["files[multi_file][$i]"] = $file_path; + } + + // @todo: Replace after https://www.drupal.org/project/drupal/issues/2917885 + $this->drupalGet("admin/appearance/settings/$theme"); + $submit_xpath = $this->assertSession()->buttonExists('Save configuration')->getXpath(); + $client = $this->getSession()->getDriver()->getClient(); + $form = $client->getCrawler()->filterXPath($submit_xpath)->form(); + $client->request($form->getMethod(), $form->getUri(), $form->getPhpValues(), $edit); + + $page = $this->getSession()->getPage(); + $this->assertNotContains('Only files with the following extensions are allowed', $page->getContent()); + $this->assertContains('The configuration options have been saved.', $page->getContent()); + $this->assertContains('file1.wtf', $page->getContent()); + $this->assertContains('file2.wtf', $page->getContent()); + } + +} diff --git a/web/core/modules/language/src/ConfigurableLanguageManager.php b/web/core/modules/language/src/ConfigurableLanguageManager.php index 13079f855..25927b525 100644 --- a/web/core/modules/language/src/ConfigurableLanguageManager.php +++ b/web/core/modules/language/src/ConfigurableLanguageManager.php @@ -90,11 +90,11 @@ class ConfigurableLanguageManager extends LanguageManager implements Configurabl protected $initialized = FALSE; /** - * Whether already in the process of language initialization. + * Whether language types are in the process of language initialization. * - * @var bool + * @var bool[] */ - protected $initializing = FALSE; + protected $initializing = []; /** * {@inheritdoc} @@ -213,12 +213,12 @@ class ConfigurableLanguageManager extends LanguageManager implements Configurabl $this->negotiatedLanguages[$type] = $this->getDefaultLanguage(); if ($this->negotiator && $this->isMultilingual()) { - if (!$this->initializing) { - $this->initializing = TRUE; + if (!isset($this->initializing[$type])) { + $this->initializing[$type] = TRUE; $negotiation = $this->negotiator->initializeType($type); $this->negotiatedLanguages[$type] = reset($negotiation); $this->negotiatedMethods[$type] = key($negotiation); - $this->initializing = FALSE; + unset($this->initializing[$type]); } // If the current interface language needs to be retrieved during // initialization we return the system language. This way string diff --git a/web/core/modules/language/tests/src/Functional/ConfigurableLanguageManagerTest.php b/web/core/modules/language/tests/src/Functional/ConfigurableLanguageManagerTest.php new file mode 100644 index 000000000..d36aee0ac --- /dev/null +++ b/web/core/modules/language/tests/src/Functional/ConfigurableLanguageManagerTest.php @@ -0,0 +1,189 @@ +createUser([], '', TRUE); + $this->drupalLogin($user); + ConfigurableLanguage::createFromLangcode('es')->save(); + + // Create a page node type and make it translatable. + NodeType::create([ + 'type' => 'page', + 'name' => t('Page'), + ])->save(); + + $config = ContentLanguageSettings::loadByEntityTypeBundle('node', 'page'); + $config->setDefaultLangcode('en') + ->setLanguageAlterable(TRUE) + ->save(); + + // Create a Node with title 'English' and translate it to Spanish. + $node = Node::create([ + 'type' => 'page', + 'title' => 'English', + ]); + $node->save(); + $node->addTranslation('es', ['title' => 'Español']); + $node->save(); + + // Enable both language_interface and language_content language negotiation. + \Drupal::getContainer()->get('language_negotiator')->updateConfiguration([ + 'language_interface', + 'language_content', + ]); + + // Set the preferred language of the user for admin pages to English. + $user->set('preferred_admin_langcode', 'en')->save(); + + // Make sure node edit pages are administration pages. + $this->config('node.settings')->set('use_admin_theme', '1')->save(); + $this->container->get('router.builder')->rebuild(); + + // Place a Block with a translatable string on the page. + $this->placeBlock('system_powered_by_block', ['region' => 'content']); + + // Load the Spanish Node page once, to register the translatable string. + $this->drupalGet('/es/node/1'); + + // Translate the Powered by string. + /** @var \Drupal\locale\StringStorageInterface $string_storage */ + $string_storage = \Drupal::getContainer()->get('locale.storage'); + $source = $string_storage->findString(['source' => 'Powered by Drupal']); + $string_storage->createTranslation([ + 'lid' => $source->lid, + 'language' => 'es', + 'translation' => 'Funciona con ...', + ])->save(); + // Invalidate caches so that the new translation will be used. + Cache::invalidateTags(['rendered', 'locale']); + } + + /** + * Test translation with URL and Preferred Admin Language negotiators. + * + * The interface language uses the preferred language for admin pages of the + * user and after that the URL. The Content uses just the URL. + */ + public function testUrlContentTranslationWithPreferredAdminLanguage() { + $assert_session = $this->assertSession(); + // Set the interface language to use the preferred administration language + // and then the URL. + /** @var \Drupal\language\LanguageNegotiatorInterface $language_negotiator */ + $language_negotiator = \Drupal::getContainer()->get('language_negotiator'); + $language_negotiator->saveConfiguration('language_interface', [ + 'language-user-admin' => 1, + 'language-url' => 2, + 'language-selected' => 3, + ]); + // Set Content Language Negotiator to use just the URL. + $language_negotiator->saveConfiguration('language_content', [ + 'language-url' => 4, + 'language-selected' => 5, + ]); + + // See if the full view of the node in english is present and the + // string in the Powered By Block is in English. + $this->drupalGet('/node/1'); + $assert_session->pageTextContains('English'); + $assert_session->pageTextContains('Powered by'); + + // Load the spanish node page again and see if both the node and the string + // are translated. + $this->drupalGet('/es/node/1'); + $assert_session->pageTextContains('Español'); + $assert_session->pageTextContains('Funciona con'); + $assert_session->pageTextNotContains('Powered by'); + + // Check if the Powered by string is shown in English on an + // administration page, and the node content is shown in Spanish. + $this->drupalGet('/es/node/1/edit'); + $assert_session->pageTextContains('Español'); + $assert_session->pageTextContains('Powered by'); + $assert_session->pageTextNotContains('Funciona con'); + } + + /** + * Test translation with URL and Session Language Negotiators. + */ + public function testUrlContentTranslationWithSessionLanguage() { + $assert_session = $this->assertSession(); + /** @var \Drupal\language\LanguageNegotiatorInterface $language_negotiator */ + $language_negotiator = \Drupal::getContainer()->get('language_negotiator'); + // Set Interface Language Negotiator to Session. + $language_negotiator->saveConfiguration('language_interface', [ + 'language-session' => 1, + 'language-url' => 2, + 'language-selected' => 3, + ]); + + // Set Content Language Negotiator to URL. + $language_negotiator->saveConfiguration('language_content', [ + 'language-url' => 4, + 'language-selected' => 5, + ]); + + // See if the full view of the node in english is present and the + // string in the Powered By Block is in English. + $this->drupalGet('/node/1'); + $assert_session->pageTextContains('English'); + $assert_session->pageTextContains('Powered by'); + + // The language session variable has not been set yet, so + // The string should be in Spanish. + $this->drupalGet('/es/node/1'); + $assert_session->pageTextContains('Español'); + $assert_session->pageTextNotContains('Powered by'); + $assert_session->pageTextContains('Funciona con'); + + // Set the session language to Spanish but load the English node page. + $this->drupalGet('/node/1', ['query' => ['language' => 'es']]); + $assert_session->pageTextContains('English'); + $assert_session->pageTextNotContains('Español'); + $assert_session->pageTextContains('Funciona con'); + $assert_session->pageTextNotContains('Powered by'); + + // Set the session language to English but load the node page in Spanish. + $this->drupalGet('/es/node/1', ['query' => ['language' => 'en']]); + $assert_session->pageTextNotContains('English'); + $assert_session->pageTextContains('Español'); + $assert_session->pageTextNotContains('Funciona con'); + $assert_session->pageTextContains('Powered by'); + } + +} diff --git a/web/core/modules/language/tests/src/Kernel/Migrate/d6/MigrateLanguageContentTaxonomyVocabularySettingsTest.php b/web/core/modules/language/tests/src/Kernel/Migrate/d6/MigrateLanguageContentTaxonomyVocabularySettingsTest.php index 1f7fd035d..b7b3f4b8f 100644 --- a/web/core/modules/language/tests/src/Kernel/Migrate/d6/MigrateLanguageContentTaxonomyVocabularySettingsTest.php +++ b/web/core/modules/language/tests/src/Kernel/Migrate/d6/MigrateLanguageContentTaxonomyVocabularySettingsTest.php @@ -45,9 +45,9 @@ class MigrateLanguageContentTaxonomyVocabularySettingsTest extends MigrateDrupal // Set language to vocabulary. $this->assertLanguageContentSettings($target_entity, 'vocabulary_2_i_1_', 'fr', FALSE, ['enabled' => FALSE]); // Localize terms. - $this->assertLanguageContentSettings($target_entity, 'vocabulary_3_i_2_', LanguageInterface::LANGCODE_SITE_DEFAULT, TRUE, ['enabled' => TRUE]); + $this->assertLanguageContentSettings($target_entity, 'vocabulary_3_i_2_', LanguageInterface::LANGCODE_SITE_DEFAULT, TRUE, ['enabled' => FALSE]); // None translation enabled. - $this->assertLanguageContentSettings($target_entity, 'vocabulary_name_much_longer_than', LanguageInterface::LANGCODE_SITE_DEFAULT, FALSE, ['enabled' => FALSE]); + $this->assertLanguageContentSettings($target_entity, 'vocabulary_name_much_longer_than', LanguageInterface::LANGCODE_SITE_DEFAULT, TRUE, ['enabled' => TRUE]); $this->assertLanguageContentSettings($target_entity, 'tags', LanguageInterface::LANGCODE_SITE_DEFAULT, FALSE, ['enabled' => FALSE]); $this->assertLanguageContentSettings($target_entity, 'forums', LanguageInterface::LANGCODE_SITE_DEFAULT, FALSE, ['enabled' => FALSE]); $this->assertLanguageContentSettings($target_entity, 'type', LanguageInterface::LANGCODE_SITE_DEFAULT, FALSE, ['enabled' => FALSE]); diff --git a/web/core/modules/layout_builder/css/layout-builder.css b/web/core/modules/layout_builder/css/layout-builder.css index c920f23bd..edb0c48cb 100644 --- a/web/core/modules/layout_builder/css/layout-builder.css +++ b/web/core/modules/layout_builder/css/layout-builder.css @@ -85,3 +85,32 @@ display: block; padding-top: 0.55em; } + +#drupal-off-canvas .inline-block-create-button { + display: block; + padding: 24px; + padding-left: 44px; + font-size: 16px; + color: #eee; + background: url(../../../misc/icons/bebebe/plus.svg) transparent 16px no-repeat; +} + +#drupal-off-canvas .inline-block-create-button, +#drupal-off-canvas .inline-block-list__item { + margin: 0 -20px; + background-color: #444; +} + +#drupal-off-canvas .inline-block-create-button:hover, +#drupal-off-canvas .inline-block-list__item:hover { + background-color: #333; +} + +#drupal-off-canvas .inline-block-list { + margin-bottom: 15px; +} + +#drupal-off-canvas .inline-block-list__item { + display: block; + padding: 15px 0 15px 25px; +} diff --git a/web/core/modules/layout_builder/layout_builder.info.yml b/web/core/modules/layout_builder/layout_builder.info.yml index dfd9922df..f70cdcaec 100644 --- a/web/core/modules/layout_builder/layout_builder.info.yml +++ b/web/core/modules/layout_builder/layout_builder.info.yml @@ -9,3 +9,5 @@ dependencies: - drupal:contextual # @todo Discuss removing in https://www.drupal.org/project/drupal/issues/2935999. - drupal:field_ui + # @todo Discuss removing in https://www.drupal.org/project/drupal/issues/3003610. + - drupal:block diff --git a/web/core/modules/layout_builder/layout_builder.module b/web/core/modules/layout_builder/layout_builder.module index 5d7c60615..373f7d8a0 100644 --- a/web/core/modules/layout_builder/layout_builder.module +++ b/web/core/modules/layout_builder/layout_builder.module @@ -19,6 +19,7 @@ use Drupal\layout_builder\Plugin\Block\ExtraFieldBlock; use Drupal\layout_builder\InlineBlockEntityOperations; use Drupal\Core\Session\AccountInterface; use Drupal\Core\Access\AccessResult; +use Drupal\layout_builder\Plugin\SectionStorage\OverridesSectionStorage; /** * Implements hook_help(). @@ -62,8 +63,8 @@ function layout_builder_entity_type_alter(array &$entity_types) { function layout_builder_form_entity_form_display_edit_form_alter(&$form, FormStateInterface $form_state) { // Hides the Layout Builder field. It is rendered directly in // \Drupal\layout_builder\Entity\LayoutBuilderEntityViewDisplay::buildMultiple(). - unset($form['fields']['layout_builder__layout']); - $key = array_search('layout_builder__layout', $form['#fields']); + unset($form['fields'][OverridesSectionStorage::FIELD_NAME]); + $key = array_search(OverridesSectionStorage::FIELD_NAME, $form['#fields']); if ($key !== FALSE) { unset($form['#fields'][$key]); } @@ -177,7 +178,7 @@ function layout_builder_cron() { function layout_builder_plugin_filter_block_alter(array &$definitions, array $extra, $consumer) { // @todo Determine the 'inline_block' blocks should be allowed outside // of layout_builder https://www.drupal.org/node/2979142. - if ($consumer !== 'layout_builder') { + if ($consumer !== 'layout_builder' || !isset($extra['list']) || $extra['list'] !== 'inline_blocks') { foreach ($definitions as $id => $definition) { if ($definition['id'] === 'inline_block') { unset($definitions[$id]); @@ -202,3 +203,21 @@ function layout_builder_block_content_access(EntityInterface $entity, $operation } return AccessResult::forbidden(); } + +/** + * Implements hook_plugin_filter_TYPE__CONSUMER_alter(). + */ +function layout_builder_plugin_filter_block__block_ui_alter(array &$definitions, array $extra) { + foreach ($definitions as $id => $definition) { + // Filter out any layout_builder definition with required contexts. + if ($definition['provider'] === 'layout_builder' && !empty($definition['context'])) { + /** @var \Drupal\Core\Plugin\Context\ContextDefinitionInterface $context */ + foreach ($definition['context'] as $context) { + if ($context->isRequired()) { + unset($definitions[$id]); + break; + } + } + } + } +} diff --git a/web/core/modules/layout_builder/layout_builder.routing.yml b/web/core/modules/layout_builder/layout_builder.routing.yml index 54c9cf25b..3e3ee1b02 100644 --- a/web/core/modules/layout_builder/layout_builder.routing.yml +++ b/web/core/modules/layout_builder/layout_builder.routing.yml @@ -80,6 +80,19 @@ layout_builder.add_block: section_storage: layout_builder_tempstore: TRUE +layout_builder.choose_inline_block: + path: '/layout_builder/choose/inline-block/{section_storage_type}/{section_storage}/{delta}/{region}' + defaults: + _controller: '\Drupal\layout_builder\Controller\ChooseBlockController::inlineBlockList' + _title: 'Add a new Inline Block' + requirements: + _permission: 'configure any layout' + options: + _admin_route: TRUE + parameters: + section_storage: + layout_builder_tempstore: TRUE + layout_builder.update_block: path: '/layout_builder/update/block/{section_storage_type}/{section_storage}/{delta}/{region}/{uuid}' defaults: diff --git a/web/core/modules/layout_builder/src/Controller/ChooseBlockController.php b/web/core/modules/layout_builder/src/Controller/ChooseBlockController.php index ac8514c48..9bd76bacc 100644 --- a/web/core/modules/layout_builder/src/Controller/ChooseBlockController.php +++ b/web/core/modules/layout_builder/src/Controller/ChooseBlockController.php @@ -5,6 +5,7 @@ namespace Drupal\layout_builder\Controller; use Drupal\Core\Ajax\AjaxHelperTrait; use Drupal\Core\Block\BlockManagerInterface; use Drupal\Core\DependencyInjection\ContainerInjectionInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\Core\Url; use Drupal\layout_builder\Context\LayoutBuilderContextTrait; @@ -29,14 +30,24 @@ class ChooseBlockController implements ContainerInjectionInterface { */ protected $blockManager; + /** + * The entity type manager. + * + * @var \Drupal\Core\Entity\EntityTypeManagerInterface + */ + protected $entityTypeManager; + /** * ChooseBlockController constructor. * * @param \Drupal\Core\Block\BlockManagerInterface $block_manager * The block manager. + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager + * The entity type manager. */ - public function __construct(BlockManagerInterface $block_manager) { + public function __construct(BlockManagerInterface $block_manager, EntityTypeManagerInterface $entity_type_manager) { $this->blockManager = $block_manager; + $this->entityTypeManager = $entity_type_manager; } /** @@ -44,7 +55,8 @@ class ChooseBlockController implements ContainerInjectionInterface { */ public static function create(ContainerInterface $container) { return new static( - $container->get('plugin.manager.block') + $container->get('plugin.manager.block'), + $container->get('entity_type.manager') ); } @@ -63,8 +75,43 @@ class ChooseBlockController implements ContainerInjectionInterface { */ public function build(SectionStorageInterface $section_storage, $delta, $region) { $build['#title'] = $this->t('Choose a block'); - $build['#type'] = 'container'; - $build['#attributes']['class'][] = 'block-categories'; + if ($this->entityTypeManager->hasDefinition('block_content_type') && $types = $this->entityTypeManager->getStorage('block_content_type')->loadMultiple()) { + if (count($types) === 1) { + $type = reset($types); + $plugin_id = 'inline_block:' . $type->id(); + if ($this->blockManager->hasDefinition($plugin_id)) { + $url = Url::fromRoute('layout_builder.add_block', [ + 'section_storage_type' => $section_storage->getStorageType(), + 'section_storage' => $section_storage->getStorageId(), + 'delta' => $delta, + 'region' => $region, + 'plugin_id' => $plugin_id, + ]); + } + } + else { + $url = Url::fromRoute('layout_builder.choose_inline_block', [ + 'section_storage_type' => $section_storage->getStorageType(), + 'section_storage' => $section_storage->getStorageId(), + 'delta' => $delta, + 'region' => $region, + ]); + } + if (isset($url)) { + $build['add_block'] = [ + '#type' => 'link', + '#url' => $url, + '#title' => $this->t('Create @entity_type', [ + '@entity_type' => $this->entityTypeManager->getDefinition('block_content')->getSingularLabel(), + ]), + '#attributes' => $this->getAjaxAttributes(), + ]; + $build['add_block']['#attributes']['class'][] = 'inline-block-create-button'; + } + } + + $block_categories['#type'] = 'container'; + $block_categories['#attributes']['class'][] = 'block-categories'; // @todo Explicitly cast delta to an integer, remove this in // https://www.drupal.org/project/drupal/issues/2984509. @@ -75,35 +122,116 @@ class ChooseBlockController implements ContainerInjectionInterface { 'delta' => $delta, 'region' => $region, ]); - foreach ($this->blockManager->getGroupedDefinitions($definitions) as $category => $blocks) { - $build[$category]['#type'] = 'details'; - $build[$category]['#open'] = TRUE; - $build[$category]['#title'] = $category; - $build[$category]['links'] = [ - '#theme' => 'links', - ]; - foreach ($blocks as $block_id => $block) { - $link = [ - 'title' => $block['admin_label'], - 'url' => Url::fromRoute('layout_builder.add_block', - [ - 'section_storage_type' => $section_storage->getStorageType(), - 'section_storage' => $section_storage->getStorageId(), - 'delta' => $delta, - 'region' => $region, - 'plugin_id' => $block_id, - ] - ), - ]; - if ($this->isAjax()) { - $link['attributes']['class'][] = 'use-ajax'; - $link['attributes']['data-dialog-type'][] = 'dialog'; - $link['attributes']['data-dialog-renderer'][] = 'off_canvas'; - } - $build[$category]['links']['#links'][] = $link; + $grouped_definitions = $this->blockManager->getGroupedDefinitions($definitions); + foreach ($grouped_definitions as $category => $blocks) { + $block_categories[$category]['#type'] = 'details'; + $block_categories[$category]['#open'] = TRUE; + $block_categories[$category]['#title'] = $category; + $block_categories[$category]['links'] = $this->getBlockLinks($section_storage, $delta, $region, $blocks); + } + $build['block_categories'] = $block_categories; + return $build; + } + + /** + * Provides the UI for choosing a new inline block. + * + * @param \Drupal\layout_builder\SectionStorageInterface $section_storage + * The section storage. + * @param int $delta + * The delta of the section to splice. + * @param string $region + * The region the block is going in. + * + * @return array + * A render array. + */ + public function inlineBlockList(SectionStorageInterface $section_storage, $delta, $region) { + $definitions = $this->blockManager->getFilteredDefinitions('layout_builder', $this->getAvailableContexts($section_storage), [ + 'section_storage' => $section_storage, + 'region' => $region, + 'list' => 'inline_blocks', + ]); + $blocks = $this->blockManager->getGroupedDefinitions($definitions); + $build = []; + if (isset($blocks['Inline blocks'])) { + $build['links'] = $this->getBlockLinks($section_storage, $delta, $region, $blocks['Inline blocks']); + $build['links']['#attributes']['class'][] = 'inline-block-list'; + foreach ($build['links']['#links'] as &$link) { + $link['attributes']['class'][] = 'inline-block-list__item'; } + $build['back_button'] = [ + '#type' => 'link', + '#url' => Url::fromRoute('layout_builder.choose_block', + [ + 'section_storage_type' => $section_storage->getStorageType(), + 'section_storage' => $section_storage->getStorageId(), + 'delta' => $delta, + 'region' => $region, + ] + ), + '#title' => $this->t('Back'), + '#attributes' => $this->getAjaxAttributes(), + ]; } return $build; } + /** + * Gets a render array of block links. + * + * @param \Drupal\layout_builder\SectionStorageInterface $section_storage + * The section storage. + * @param int $delta + * The delta of the section to splice. + * @param string $region + * The region the block is going in. + * @param array $blocks + * The information for each block. + * + * @return array + * The block links render array. + */ + protected function getBlockLinks(SectionStorageInterface $section_storage, $delta, $region, array $blocks) { + $links = []; + foreach ($blocks as $block_id => $block) { + $link = [ + 'title' => $block['admin_label'], + 'url' => Url::fromRoute('layout_builder.add_block', + [ + 'section_storage_type' => $section_storage->getStorageType(), + 'section_storage' => $section_storage->getStorageId(), + 'delta' => $delta, + 'region' => $region, + 'plugin_id' => $block_id, + ] + ), + 'attributes' => $this->getAjaxAttributes(), + ]; + + $links[] = $link; + } + return [ + '#theme' => 'links', + '#links' => $links, + ]; + } + + /** + * Get dialog attributes if an ajax request. + * + * @return array + * The attributes array. + */ + protected function getAjaxAttributes() { + if ($this->isAjax()) { + return [ + 'class' => ['use-ajax'], + 'data-dialog-type' => 'dialog', + 'data-dialog-renderer' => 'off_canvas', + ]; + } + return []; + } + } diff --git a/web/core/modules/layout_builder/src/Controller/LayoutBuilderController.php b/web/core/modules/layout_builder/src/Controller/LayoutBuilderController.php index c01fea544..00bb40261 100644 --- a/web/core/modules/layout_builder/src/Controller/LayoutBuilderController.php +++ b/web/core/modules/layout_builder/src/Controller/LayoutBuilderController.php @@ -2,6 +2,7 @@ namespace Drupal\layout_builder\Controller; +use Drupal\Core\Ajax\AjaxHelperTrait; use Drupal\Core\DependencyInjection\ContainerInjectionInterface; use Drupal\Core\Messenger\MessengerInterface; use Drupal\Core\Plugin\PluginFormInterface; @@ -24,6 +25,7 @@ class LayoutBuilderController implements ContainerInjectionInterface { use LayoutBuilderContextTrait; use StringTranslationTrait; + use AjaxHelperTrait; /** * The layout tempstore repository. @@ -90,6 +92,11 @@ class LayoutBuilderController implements ContainerInjectionInterface { $this->prepareLayout($section_storage, $is_rebuilding); $output = []; + if ($this->isAjax()) { + $output['status_messages'] = [ + '#type' => 'status_messages', + ]; + } $count = 0; for ($i = 0; $i < $section_storage->count(); $i++) { $output[] = $this->buildAddSectionLink($section_storage, $count); @@ -114,6 +121,11 @@ class LayoutBuilderController implements ContainerInjectionInterface { * Indicates if the layout is rebuilding. */ protected function prepareLayout(SectionStorageInterface $section_storage, $is_rebuilding) { + // If the layout has pending changes, add a warning. + if ($this->layoutTempstoreRepository->has($section_storage)) { + $this->messenger->addWarning($this->t('You have unsaved changes.')); + } + // Only add sections if the layout is new and empty. if (!$is_rebuilding && $section_storage->count() === 0) { $sections = []; @@ -269,7 +281,7 @@ class LayoutBuilderController implements ContainerInjectionInterface { ], 'remove' => [ '#type' => 'link', - '#title' => $this->t('Remove section'), + '#title' => $this->t('Remove section @section', ['@section' => $delta + 1]), '#url' => Url::fromRoute('layout_builder.remove_section', [ 'section_storage_type' => $storage_type, 'section_storage' => $storage_id, diff --git a/web/core/modules/layout_builder/src/Entity/LayoutBuilderEntityViewDisplay.php b/web/core/modules/layout_builder/src/Entity/LayoutBuilderEntityViewDisplay.php index 0118768f7..7f10d6f51 100644 --- a/web/core/modules/layout_builder/src/Entity/LayoutBuilderEntityViewDisplay.php +++ b/web/core/modules/layout_builder/src/Entity/LayoutBuilderEntityViewDisplay.php @@ -9,6 +9,7 @@ use Drupal\Core\Plugin\Context\EntityContext; use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\field\Entity\FieldConfig; use Drupal\field\Entity\FieldStorageConfig; +use Drupal\layout_builder\Plugin\SectionStorage\OverridesSectionStorage; use Drupal\layout_builder\Section; use Drupal\layout_builder\SectionComponent; use Drupal\layout_builder\SectionStorage\SectionStorageTrait; @@ -110,10 +111,10 @@ class LayoutBuilderEntityViewDisplay extends BaseEntityViewDisplay implements La $bundle = $this->getTargetBundle(); if ($new_value) { - $this->addSectionField($entity_type_id, $bundle, 'layout_builder__layout'); + $this->addSectionField($entity_type_id, $bundle, OverridesSectionStorage::FIELD_NAME); } else { - $this->removeSectionField($entity_type_id, $bundle, 'layout_builder__layout'); + $this->removeSectionField($entity_type_id, $bundle, OverridesSectionStorage::FIELD_NAME); } } @@ -274,8 +275,8 @@ class LayoutBuilderEntityViewDisplay extends BaseEntityViewDisplay implements La * The sections. */ protected function getRuntimeSections(FieldableEntityInterface $entity) { - if ($this->isOverridable() && !$entity->get('layout_builder__layout')->isEmpty()) { - return $entity->get('layout_builder__layout')->getSections(); + if ($this->isOverridable() && !$entity->get(OverridesSectionStorage::FIELD_NAME)->isEmpty()) { + return $entity->get(OverridesSectionStorage::FIELD_NAME)->getSections(); } return $this->getSections(); diff --git a/web/core/modules/layout_builder/src/EventSubscriber/BlockComponentRenderArray.php b/web/core/modules/layout_builder/src/EventSubscriber/BlockComponentRenderArray.php index c2e3bfb18..276e680b4 100644 --- a/web/core/modules/layout_builder/src/EventSubscriber/BlockComponentRenderArray.php +++ b/web/core/modules/layout_builder/src/EventSubscriber/BlockComponentRenderArray.php @@ -5,6 +5,8 @@ namespace Drupal\layout_builder\EventSubscriber; use Drupal\block_content\Access\RefinableDependentAccessInterface; use Drupal\Core\Access\AccessResult; use Drupal\Core\Block\BlockPluginInterface; +use Drupal\Core\Render\Element; +use Drupal\Core\Render\PreviewFallbackInterface; use Drupal\Core\Session\AccountInterface; use Drupal\layout_builder\Access\LayoutPreviewAccessAllowed; use Drupal\layout_builder\Event\SectionComponentBuildRenderArrayEvent; @@ -98,6 +100,9 @@ class BlockComponentRenderArray implements EventSubscriberInterface { '#weight' => $event->getComponent()->getWeight(), 'content' => $block->build(), ]; + if ($event->inPreview() && Element::isEmpty($build['content']) && $block instanceof PreviewFallbackInterface) { + $build['content']['#markup'] = $block->getPreviewFallbackString(); + } $event->setBuild($build); } } diff --git a/web/core/modules/layout_builder/src/Form/LayoutBuilderEntityViewDisplayForm.php b/web/core/modules/layout_builder/src/Form/LayoutBuilderEntityViewDisplayForm.php index fea971bbc..72edc2f5b 100644 --- a/web/core/modules/layout_builder/src/Form/LayoutBuilderEntityViewDisplayForm.php +++ b/web/core/modules/layout_builder/src/Form/LayoutBuilderEntityViewDisplayForm.php @@ -7,6 +7,7 @@ use Drupal\Core\Field\FieldDefinitionInterface; use Drupal\Core\Form\FormStateInterface; use Drupal\field_ui\Form\EntityViewDisplayEditForm; use Drupal\layout_builder\Entity\LayoutEntityDisplayInterface; +use Drupal\layout_builder\Plugin\SectionStorage\OverridesSectionStorage; use Drupal\layout_builder\SectionStorageInterface; /** @@ -48,8 +49,8 @@ class LayoutBuilderEntityViewDisplayForm extends EntityViewDisplayEditForm { $form = parent::form($form, $form_state); // Remove the Layout Builder field from the list. - $form['#fields'] = array_diff($form['#fields'], ['layout_builder__layout']); - unset($form['fields']['layout_builder__layout']); + $form['#fields'] = array_diff($form['#fields'], [OverridesSectionStorage::FIELD_NAME]); + unset($form['fields'][OverridesSectionStorage::FIELD_NAME]); $is_enabled = $this->entity->isLayoutBuilderEnabled(); if ($is_enabled) { @@ -133,7 +134,7 @@ class LayoutBuilderEntityViewDisplayForm extends EntityViewDisplayEditForm { $entity_type = $this->entityTypeManager->getDefinition($display->getTargetEntityTypeId()); $query = $this->entityTypeManager->getStorage($display->getTargetEntityTypeId())->getQuery() - ->exists('layout_builder__layout'); + ->exists(OverridesSectionStorage::FIELD_NAME); if ($bundle_key = $entity_type->getKey('bundle')) { $query->condition($bundle_key, $display->getTargetBundle()); } diff --git a/web/core/modules/layout_builder/src/LayoutEntityHelperTrait.php b/web/core/modules/layout_builder/src/LayoutEntityHelperTrait.php index 912402754..fc22168a9 100644 --- a/web/core/modules/layout_builder/src/LayoutEntityHelperTrait.php +++ b/web/core/modules/layout_builder/src/LayoutEntityHelperTrait.php @@ -6,6 +6,7 @@ use Drupal\Component\Plugin\DerivativeInspectionInterface; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\FieldableEntityInterface; use Drupal\layout_builder\Entity\LayoutEntityDisplayInterface; +use Drupal\layout_builder\Plugin\SectionStorage\OverridesSectionStorage; /** * Methods to help with entities using the layout builder. @@ -65,7 +66,7 @@ trait LayoutEntityHelperTrait { return $entity->getSections(); } elseif ($this->isEntityUsingFieldOverride($entity)) { - return $entity->get('layout_builder__layout')->getSections(); + return $entity->get(OverridesSectionStorage::FIELD_NAME)->getSections(); } return NULL; } @@ -102,7 +103,7 @@ trait LayoutEntityHelperTrait { * TRUE if the entity is using a field for a layout override. */ protected function isEntityUsingFieldOverride(EntityInterface $entity) { - return $entity instanceof FieldableEntityInterface && $entity->hasField('layout_builder__layout'); + return $entity instanceof FieldableEntityInterface && $entity->hasField(OverridesSectionStorage::FIELD_NAME); } } diff --git a/web/core/modules/layout_builder/src/LayoutTempstoreRepository.php b/web/core/modules/layout_builder/src/LayoutTempstoreRepository.php index 39725afc7..69676861d 100644 --- a/web/core/modules/layout_builder/src/LayoutTempstoreRepository.php +++ b/web/core/modules/layout_builder/src/LayoutTempstoreRepository.php @@ -45,6 +45,15 @@ class LayoutTempstoreRepository implements LayoutTempstoreRepositoryInterface { return $section_storage; } + /** + * {@inheritdoc} + */ + public function has(SectionStorageInterface $section_storage) { + $id = $section_storage->getStorageId(); + $tempstore = $this->getTempstore($section_storage)->get($id); + return !empty($tempstore['section_storage']); + } + /** * {@inheritdoc} */ diff --git a/web/core/modules/layout_builder/src/LayoutTempstoreRepositoryInterface.php b/web/core/modules/layout_builder/src/LayoutTempstoreRepositoryInterface.php index 4972a47f6..67dc59ca9 100644 --- a/web/core/modules/layout_builder/src/LayoutTempstoreRepositoryInterface.php +++ b/web/core/modules/layout_builder/src/LayoutTempstoreRepositoryInterface.php @@ -35,6 +35,17 @@ interface LayoutTempstoreRepositoryInterface { */ public function set(SectionStorageInterface $section_storage); + /** + * Checks for the existence of a tempstore version of a section storage. + * + * @param \Drupal\layout_builder\SectionStorageInterface $section_storage + * The section storage to check for in tempstore. + * + * @return bool + * TRUE if there is a tempstore version of this section storage. + */ + public function has(SectionStorageInterface $section_storage); + /** * Removes the tempstore version of a section storage. * diff --git a/web/core/modules/layout_builder/src/Plugin/Block/ExtraFieldBlock.php b/web/core/modules/layout_builder/src/Plugin/Block/ExtraFieldBlock.php index ddb7f01fd..6b9910376 100644 --- a/web/core/modules/layout_builder/src/Plugin/Block/ExtraFieldBlock.php +++ b/web/core/modules/layout_builder/src/Plugin/Block/ExtraFieldBlock.php @@ -130,13 +130,22 @@ class ExtraFieldBlock extends BlockBase implements ContextAwarePluginInterface, // render array. If the hook is invoked the placeholder will be // replaced. // @see ::replaceFieldPlaceholder() - '#markup' => new TranslatableMarkup('Placeholder for the "@field" field', ['@field' => $extra_fields['display'][$this->fieldName]['label']]), + '#markup' => $this->getPreviewFallbackString(), ]; } CacheableMetadata::createFromObject($this)->applyTo($build); return $build; } + /** + * {@inheritdoc} + */ + public function getPreviewFallbackString() { + $entity = $this->getEntity(); + $extra_fields = $this->entityFieldManager->getExtraFields($entity->getEntityTypeId(), $entity->bundle()); + return new TranslatableMarkup('Placeholder for the "@field" field', ['@field' => $extra_fields['display'][$this->fieldName]['label']]); + } + /** * Replaces all placeholders for a given field. * diff --git a/web/core/modules/layout_builder/src/Plugin/Block/FieldBlock.php b/web/core/modules/layout_builder/src/Plugin/Block/FieldBlock.php index bb8ed1179..88e608f6f 100644 --- a/web/core/modules/layout_builder/src/Plugin/Block/FieldBlock.php +++ b/web/core/modules/layout_builder/src/Plugin/Block/FieldBlock.php @@ -17,7 +17,6 @@ use Drupal\Core\Field\FormatterPluginManager; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\Core\Plugin\ContextAwarePluginInterface; -use Drupal\Core\Render\Element; use Drupal\Core\Session\AccountInterface; use Drupal\Core\StringTranslation\TranslatableMarkup; use Psr\Log\LoggerInterface; @@ -160,13 +159,17 @@ class FieldBlock extends BlockBase implements ContextAwarePluginInterface, Conta $build = []; $this->logger->warning('The field "%field" failed to render with the error of "%error".', ['%field' => $this->fieldName, '%error' => $e->getMessage()]); } - if (!empty($entity->in_preview) && !Element::getVisibleChildren($build)) { - $build['content']['#markup'] = new TranslatableMarkup('Placeholder for the "@field" field', ['@field' => $this->getFieldDefinition()->getLabel()]); - } CacheableMetadata::createFromObject($this)->applyTo($build); return $build; } + /** + * {@inheritdoc} + */ + public function getPreviewFallbackString() { + return new TranslatableMarkup('Placeholder for the "@field" field', ['@field' => $this->getFieldDefinition()->getLabel()]); + } + /** * {@inheritdoc} */ diff --git a/web/core/modules/layout_builder/src/Plugin/Derivative/FieldBlockDeriver.php b/web/core/modules/layout_builder/src/Plugin/Derivative/FieldBlockDeriver.php index 3e15fcd78..57d2c1ec7 100644 --- a/web/core/modules/layout_builder/src/Plugin/Derivative/FieldBlockDeriver.php +++ b/web/core/modules/layout_builder/src/Plugin/Derivative/FieldBlockDeriver.php @@ -106,7 +106,7 @@ class FieldBlockDeriver extends DeriverBase implements ContainerDeriverInterface $derivative['default_formatter'] = $field_type_definition['default_formatter']; } - $derivative['category'] = $this->t('@entity', ['@entity' => $entity_type_labels[$entity_type_id]]); + $derivative['category'] = $this->t('@entity fields', ['@entity' => $entity_type_labels[$entity_type_id]]); $derivative['admin_label'] = $field_definition->getLabel(); diff --git a/web/core/modules/layout_builder/src/Plugin/Derivative/LayoutBuilderLocalTaskDeriver.php b/web/core/modules/layout_builder/src/Plugin/Derivative/LayoutBuilderLocalTaskDeriver.php index e2053f342..9e4a2ce42 100644 --- a/web/core/modules/layout_builder/src/Plugin/Derivative/LayoutBuilderLocalTaskDeriver.php +++ b/web/core/modules/layout_builder/src/Plugin/Derivative/LayoutBuilderLocalTaskDeriver.php @@ -3,11 +3,11 @@ namespace Drupal\layout_builder\Plugin\Derivative; use Drupal\Component\Plugin\Derivative\DeriverBase; -use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; -use Drupal\Core\Entity\FieldableEntityInterface; use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface; use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\layout_builder\Plugin\SectionStorage\SectionStorageLocalTaskProviderInterface; +use Drupal\layout_builder\SectionStorage\SectionStorageManagerInterface; use Symfony\Component\DependencyInjection\ContainerInterface; /** @@ -28,14 +28,24 @@ class LayoutBuilderLocalTaskDeriver extends DeriverBase implements ContainerDeri */ protected $entityTypeManager; + /** + * The section storage manager. + * + * @var \Drupal\layout_builder\SectionStorage\SectionStorageManagerInterface + */ + protected $sectionStorageManager; + /** * Constructs a new LayoutBuilderLocalTaskDeriver. * * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager * The entity type manager. + * @param \Drupal\layout_builder\SectionStorage\SectionStorageManagerInterface $section_storage_manager + * The section storage manager. */ - public function __construct(EntityTypeManagerInterface $entity_type_manager) { + public function __construct(EntityTypeManagerInterface $entity_type_manager, SectionStorageManagerInterface $section_storage_manager) { $this->entityTypeManager = $entity_type_manager; + $this->sectionStorageManager = $section_storage_manager; } /** @@ -43,7 +53,8 @@ class LayoutBuilderLocalTaskDeriver extends DeriverBase implements ContainerDeri */ public static function create(ContainerInterface $container, $base_plugin_id) { return new static( - $container->get('entity_type.manager') + $container->get('entity_type.manager'), + $container->get('plugin.manager.layout_builder.section_storage') ); } @@ -51,84 +62,13 @@ class LayoutBuilderLocalTaskDeriver extends DeriverBase implements ContainerDeri * {@inheritdoc} */ public function getDerivativeDefinitions($base_plugin_definition) { - foreach ($this->getEntityTypesForOverrides() as $entity_type_id => $entity_type) { - // Overrides. - $this->derivatives["layout_builder.overrides.$entity_type_id.view"] = $base_plugin_definition + [ - 'route_name' => "layout_builder.overrides.$entity_type_id.view", - 'weight' => 15, - 'title' => $this->t('Layout'), - 'base_route' => "entity.$entity_type_id.canonical", - 'cache_contexts' => ['layout_builder_is_active:' . $entity_type_id], - ]; - $this->derivatives["layout_builder.overrides.$entity_type_id.save"] = $base_plugin_definition + [ - 'route_name' => "layout_builder.overrides.$entity_type_id.save", - 'title' => $this->t('Save Layout'), - 'parent_id' => "layout_builder_ui:layout_builder.overrides.$entity_type_id.view", - 'cache_contexts' => ['layout_builder_is_active:' . $entity_type_id], - ]; - $this->derivatives["layout_builder.overrides.$entity_type_id.cancel"] = $base_plugin_definition + [ - 'route_name' => "layout_builder.overrides.$entity_type_id.cancel", - 'title' => $this->t('Cancel Layout'), - 'parent_id' => "layout_builder_ui:layout_builder.overrides.$entity_type_id.view", - 'weight' => 5, - 'cache_contexts' => ['layout_builder_is_active:' . $entity_type_id], - ]; - // @todo This link should be conditionally displayed, see - // https://www.drupal.org/node/2917777. - $this->derivatives["layout_builder.overrides.$entity_type_id.revert"] = $base_plugin_definition + [ - 'route_name' => "layout_builder.overrides.$entity_type_id.revert", - 'title' => $this->t('Revert to defaults'), - 'parent_id' => "layout_builder_ui:layout_builder.overrides.$entity_type_id.view", - 'weight' => 10, - 'cache_contexts' => ['layout_builder_is_active:' . $entity_type_id], - ]; + foreach ($this->sectionStorageManager->getDefinitions() as $plugin_id => $definition) { + $section_storage = $this->sectionStorageManager->loadEmpty($plugin_id); + if ($section_storage instanceof SectionStorageLocalTaskProviderInterface) { + $this->derivatives += $section_storage->buildLocalTasks($base_plugin_definition); + } } - - foreach ($this->getEntityTypesForDefaults() as $entity_type_id => $entity_type) { - // Defaults. - $this->derivatives["layout_builder.defaults.$entity_type_id.view"] = $base_plugin_definition + [ - 'route_name' => "layout_builder.defaults.$entity_type_id.view", - 'title' => $this->t('Manage layout'), - 'base_route' => "layout_builder.defaults.$entity_type_id.view", - ]; - $this->derivatives["layout_builder.defaults.$entity_type_id.save"] = $base_plugin_definition + [ - 'route_name' => "layout_builder.defaults.$entity_type_id.save", - 'title' => $this->t('Save Layout'), - 'parent_id' => "layout_builder_ui:layout_builder.defaults.$entity_type_id.view", - ]; - $this->derivatives["layout_builder.defaults.$entity_type_id.cancel"] = $base_plugin_definition + [ - 'route_name' => "layout_builder.defaults.$entity_type_id.cancel", - 'title' => $this->t('Cancel Layout'), - 'weight' => 5, - 'parent_id' => "layout_builder_ui:layout_builder.defaults.$entity_type_id.view", - ]; - } - return $this->derivatives; } - /** - * Returns an array of entity types relevant for defaults. - * - * @return \Drupal\Core\Entity\EntityTypeInterface[] - * An array of entity types. - */ - protected function getEntityTypesForDefaults() { - return array_filter($this->entityTypeManager->getDefinitions(), function (EntityTypeInterface $entity_type) { - return $entity_type->entityClassImplements(FieldableEntityInterface::class) && $entity_type->hasViewBuilderClass() && $entity_type->get('field_ui_base_route'); - }); - } - - /** - * Returns an array of entity types relevant for overrides. - * - * @return \Drupal\Core\Entity\EntityTypeInterface[] - * An array of entity types. - */ - protected function getEntityTypesForOverrides() { - return array_filter($this->entityTypeManager->getDefinitions(), function (EntityTypeInterface $entity_type) { - return $entity_type->entityClassImplements(FieldableEntityInterface::class) && $entity_type->hasViewBuilderClass() && $entity_type->hasLinkTemplate('canonical'); - }); - } - } diff --git a/web/core/modules/layout_builder/src/Plugin/SectionStorage/DefaultsSectionStorage.php b/web/core/modules/layout_builder/src/Plugin/SectionStorage/DefaultsSectionStorage.php index 6fab11996..d35041d03 100644 --- a/web/core/modules/layout_builder/src/Plugin/SectionStorage/DefaultsSectionStorage.php +++ b/web/core/modules/layout_builder/src/Plugin/SectionStorage/DefaultsSectionStorage.php @@ -32,7 +32,7 @@ use Symfony\Component\Routing\RouteCollection; * experimental modules and development releases of contributed modules. * See https://www.drupal.org/core/experimental for more information. */ -class DefaultsSectionStorage extends SectionStorageBase implements ContainerFactoryPluginInterface, DefaultsSectionStorageInterface { +class DefaultsSectionStorage extends SectionStorageBase implements ContainerFactoryPluginInterface, DefaultsSectionStorageInterface, SectionStorageLocalTaskProviderInterface { /** * The entity type manager. @@ -196,6 +196,32 @@ class DefaultsSectionStorage extends SectionStorageBase implements ContainerFact } } + /** + * {@inheritdoc} + */ + public function buildLocalTasks($base_plugin_definition) { + $local_tasks = []; + foreach ($this->getEntityTypes() as $entity_type_id => $entity_type) { + $local_tasks["layout_builder.defaults.$entity_type_id.view"] = $base_plugin_definition + [ + 'route_name' => "layout_builder.defaults.$entity_type_id.view", + 'title' => $this->t('Manage layout'), + 'base_route' => "layout_builder.defaults.$entity_type_id.view", + ]; + $local_tasks["layout_builder.defaults.$entity_type_id.save"] = $base_plugin_definition + [ + 'route_name' => "layout_builder.defaults.$entity_type_id.save", + 'title' => $this->t('Save Layout'), + 'parent_id' => "layout_builder_ui:layout_builder.defaults.$entity_type_id.view", + ]; + $local_tasks["layout_builder.defaults.$entity_type_id.cancel"] = $base_plugin_definition + [ + 'route_name' => "layout_builder.defaults.$entity_type_id.cancel", + 'title' => $this->t('Cancel Layout'), + 'weight' => 5, + 'parent_id' => "layout_builder_ui:layout_builder.defaults.$entity_type_id.view", + ]; + } + return $local_tasks; + } + /** * Returns an array of relevant entity types. * diff --git a/web/core/modules/layout_builder/src/Plugin/SectionStorage/OverridesSectionStorage.php b/web/core/modules/layout_builder/src/Plugin/SectionStorage/OverridesSectionStorage.php index 3a92ee63f..bba10ef67 100644 --- a/web/core/modules/layout_builder/src/Plugin/SectionStorage/OverridesSectionStorage.php +++ b/web/core/modules/layout_builder/src/Plugin/SectionStorage/OverridesSectionStorage.php @@ -30,7 +30,14 @@ use Symfony\Component\Routing\RouteCollection; * experimental modules and development releases of contributed modules. * See https://www.drupal.org/core/experimental for more information. */ -class OverridesSectionStorage extends SectionStorageBase implements ContainerFactoryPluginInterface, OverridesSectionStorageInterface { +class OverridesSectionStorage extends SectionStorageBase implements ContainerFactoryPluginInterface, OverridesSectionStorageInterface, SectionStorageLocalTaskProviderInterface { + + /** + * The field name used by this storage. + * + * @var string + */ + const FIELD_NAME = 'layout_builder__layout'; /** * The entity type manager. @@ -127,8 +134,8 @@ class OverridesSectionStorage extends SectionStorageBase implements ContainerFac if (strpos($id, '.') !== FALSE) { list($entity_type_id, $entity_id) = explode('.', $id, 2); $entity = $this->entityTypeManager->getStorage($entity_type_id)->load($entity_id); - if ($entity instanceof FieldableEntityInterface && $entity->hasField('layout_builder__layout')) { - return $entity->get('layout_builder__layout'); + if ($entity instanceof FieldableEntityInterface && $entity->hasField(static::FIELD_NAME)) { + return $entity->get(static::FIELD_NAME); } } throw new \InvalidArgumentException(sprintf('The "%s" ID for the "%s" section storage type is invalid', $id, $this->getStorageType())); @@ -157,6 +164,45 @@ class OverridesSectionStorage extends SectionStorageBase implements ContainerFac } } + /** + * {@inheritdoc} + */ + public function buildLocalTasks($base_plugin_definition) { + $local_tasks = []; + foreach ($this->getEntityTypes() as $entity_type_id => $entity_type) { + $local_tasks["layout_builder.overrides.$entity_type_id.view"] = $base_plugin_definition + [ + 'route_name' => "layout_builder.overrides.$entity_type_id.view", + 'weight' => 15, + 'title' => $this->t('Layout'), + 'base_route' => "entity.$entity_type_id.canonical", + 'cache_contexts' => ['layout_builder_is_active:' . $entity_type_id], + ]; + $local_tasks["layout_builder.overrides.$entity_type_id.save"] = $base_plugin_definition + [ + 'route_name' => "layout_builder.overrides.$entity_type_id.save", + 'title' => $this->t('Save Layout'), + 'parent_id' => "layout_builder_ui:layout_builder.overrides.$entity_type_id.view", + 'cache_contexts' => ['layout_builder_is_active:' . $entity_type_id], + ]; + $local_tasks["layout_builder.overrides.$entity_type_id.cancel"] = $base_plugin_definition + [ + 'route_name' => "layout_builder.overrides.$entity_type_id.cancel", + 'title' => $this->t('Cancel Layout'), + 'parent_id' => "layout_builder_ui:layout_builder.overrides.$entity_type_id.view", + 'weight' => 5, + 'cache_contexts' => ['layout_builder_is_active:' . $entity_type_id], + ]; + // @todo This link should be conditionally displayed, see + // https://www.drupal.org/node/2917777. + $local_tasks["layout_builder.overrides.$entity_type_id.revert"] = $base_plugin_definition + [ + 'route_name' => "layout_builder.overrides.$entity_type_id.revert", + 'title' => $this->t('Revert to defaults'), + 'parent_id' => "layout_builder_ui:layout_builder.overrides.$entity_type_id.view", + 'weight' => 10, + 'cache_contexts' => ['layout_builder_is_active:' . $entity_type_id], + ]; + } + return $local_tasks; + } + /** * Determines if this entity type's ID is stored as an integer. * diff --git a/web/core/modules/layout_builder/src/Plugin/SectionStorage/SectionStorageLocalTaskProviderInterface.php b/web/core/modules/layout_builder/src/Plugin/SectionStorage/SectionStorageLocalTaskProviderInterface.php new file mode 100644 index 000000000..a275d6340 --- /dev/null +++ b/web/core/modules/layout_builder/src/Plugin/SectionStorage/SectionStorageLocalTaskProviderInterface.php @@ -0,0 +1,29 @@ +components as $uuid => $component) { + $this->components[$uuid] = clone $component; + } + } + } diff --git a/web/core/modules/layout_builder/src/SectionStorage/SectionStorageTrait.php b/web/core/modules/layout_builder/src/SectionStorage/SectionStorageTrait.php index 9d942c7ad..36729d2ba 100644 --- a/web/core/modules/layout_builder/src/SectionStorage/SectionStorageTrait.php +++ b/web/core/modules/layout_builder/src/SectionStorage/SectionStorageTrait.php @@ -111,4 +111,17 @@ trait SectionStorageTrait { return isset($this->getSections()[$delta]); } + /** + * Magic method: Implements a deep clone. + */ + public function __clone() { + $sections = $this->getSections(); + + foreach ($sections as $delta => $item) { + $sections[$delta] = clone $item; + } + + $this->setSections($sections); + } + } diff --git a/web/core/modules/layout_builder/tests/modules/layout_builder_fieldblock_test/config/schema/layout_builder_fieldblock_test.schema.yml b/web/core/modules/layout_builder/tests/modules/layout_builder_fieldblock_test/config/schema/layout_builder_fieldblock_test.schema.yml new file mode 100644 index 000000000..92ce34d00 --- /dev/null +++ b/web/core/modules/layout_builder/tests/modules/layout_builder_fieldblock_test/config/schema/layout_builder_fieldblock_test.schema.yml @@ -0,0 +1,3 @@ +# See \Drupal\layout_builder_fieldblock_test\Plugin\Block\FieldBlock. +block.settings.field_block_test:*:*:*: + type: block.settings.field_block:*:*:* diff --git a/web/core/modules/layout_builder/tests/modules/layout_builder_fieldblock_test/layout_builder_fieldblock_test.info.yml b/web/core/modules/layout_builder/tests/modules/layout_builder_fieldblock_test/layout_builder_fieldblock_test.info.yml new file mode 100644 index 000000000..607877e78 --- /dev/null +++ b/web/core/modules/layout_builder/tests/modules/layout_builder_fieldblock_test/layout_builder_fieldblock_test.info.yml @@ -0,0 +1,6 @@ +name: 'Layout Builder test' +type: module +description: 'Support module for testing layout building.' +package: Testing +version: VERSION +core: 8.x diff --git a/web/core/modules/layout_builder/tests/modules/layout_builder_fieldblock_test/src/Plugin/Block/FieldBlock.php b/web/core/modules/layout_builder/tests/modules/layout_builder_fieldblock_test/src/Plugin/Block/FieldBlock.php new file mode 100644 index 000000000..07a00f541 --- /dev/null +++ b/web/core/modules/layout_builder/tests/modules/layout_builder_fieldblock_test/src/Plugin/Block/FieldBlock.php @@ -0,0 +1,27 @@ +elementsCount('css', '.field--name-body', 1); // The extra field is only present once. - $this->assertTextAppearsOnce('Placeholder for the "Extra label" field'); + $assert_session->pageTextContainsOnce('Placeholder for the "Extra label" field'); // Save the defaults. $assert_session->linkExists('Save Layout'); $this->clickLink('Save Layout'); @@ -105,7 +106,7 @@ class LayoutBuilderTest extends BrowserTestBase { // The body field is only present once. $assert_session->elementsCount('css', '.field--name-body', 1); // The extra field is only present once. - $this->assertTextAppearsOnce('Placeholder for the "Extra label" field'); + $assert_session->pageTextContainsOnce('Placeholder for the "Extra label" field'); // Add a new block. $assert_session->linkExists('Add Block'); @@ -514,13 +515,68 @@ class LayoutBuilderTest extends BrowserTestBase { } /** - * Asserts that a text string only appears once on the page. + * Tests the usage of placeholders for empty blocks. * - * @param string $needle - * The string to look for. + * @see \Drupal\Core\Block\BlockPluginInterface::getPlaceholderString() + * @see \Drupal\layout_builder\EventSubscriber\BlockComponentRenderArray::onBuildRender() */ - protected function assertTextAppearsOnce($needle) { - $this->assertEquals(1, substr_count($this->getSession()->getPage()->getContent(), $needle), "'$needle' only appears once on the page."); + public function testBlockPlaceholder() { + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + + $this->drupalLogin($this->drupalCreateUser([ + 'configure any layout', + 'administer node display', + ])); + + $field_ui_prefix = 'admin/structure/types/manage/bundle_with_section_field'; + $this->drupalPostForm("$field_ui_prefix/display/default", ['layout[enabled]' => TRUE], 'Save'); + + // Customize the default view mode. + $this->drupalGet("$field_ui_prefix/display-layout/default"); + + // Add a block whose content is controlled by state and is empty by default. + $this->clickLink('Add Block'); + $this->clickLink('Test block caching'); + $page->fillField('settings[label]', 'The block label'); + $page->pressButton('Add Block'); + + $block_content = 'I am content'; + $placeholder_content = 'Placeholder for the "The block label" block'; + + // The block placeholder is displayed and there is no content. + $assert_session->pageTextContains($placeholder_content); + $assert_session->pageTextNotContains($block_content); + + // Set block content and reload the page. + \Drupal::state()->set('block_test.content', $block_content); + $this->getSession()->reload(); + + // The block placeholder is no longer displayed and the content is visible. + $assert_session->pageTextNotContains($placeholder_content); + $assert_session->pageTextContains($block_content); + } + + /** + * Tests the Block UI when Layout Builder is installed. + */ + public function testBlockUiListing() { + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + + $this->drupalLogin($this->drupalCreateUser([ + 'administer blocks', + ])); + + $this->drupalGet('admin/structure/block'); + $page->clickLink('Place block'); + + // Ensure that blocks expected to appear are available. + $assert_session->pageTextContains('Test HTML block'); + $assert_session->pageTextContains('Block test'); + // Ensure that blocks not expected to appear are not available. + $assert_session->pageTextNotContains('Body'); + $assert_session->pageTextNotContains('Content fields'); } } diff --git a/web/core/modules/layout_builder/tests/src/Functional/LayoutSectionTest.php b/web/core/modules/layout_builder/tests/src/Functional/LayoutSectionTest.php index e4af3290c..e29609b30 100644 --- a/web/core/modules/layout_builder/tests/src/Functional/LayoutSectionTest.php +++ b/web/core/modules/layout_builder/tests/src/Functional/LayoutSectionTest.php @@ -4,6 +4,7 @@ namespace Drupal\Tests\layout_builder\Functional; use Drupal\language\Entity\ConfigurableLanguage; use Drupal\layout_builder\Entity\LayoutBuilderEntityViewDisplay; +use Drupal\layout_builder\Plugin\SectionStorage\OverridesSectionStorage; use Drupal\layout_builder\Section; use Drupal\layout_builder\SectionComponent; use Drupal\Tests\BrowserTestBase; @@ -20,13 +21,6 @@ class LayoutSectionTest extends BrowserTestBase { */ public static $modules = ['field_ui', 'layout_builder', 'node', 'block_test']; - /** - * The name of the layout section field. - * - * @var string - */ - protected $fieldName = 'layout_builder__layout'; - /** * {@inheritdoc} */ @@ -226,7 +220,7 @@ class LayoutSectionTest extends BrowserTestBase { ]); $entity->addTranslation('es', [ 'title' => 'Translated node title', - $this->fieldName => [ + OverridesSectionStorage::FIELD_NAME => [ [ 'section' => new Section('layout_twocol', [], [ 'foo' => new SectionComponent('foo', 'first', [ @@ -373,7 +367,7 @@ class LayoutSectionTest extends BrowserTestBase { 'value' => 'The node body', ], ], - $this->fieldName => $section_values, + OverridesSectionStorage::FIELD_NAME => $section_values, ]); } diff --git a/web/core/modules/layout_builder/tests/src/FunctionalJavascript/FieldBlockTest.php b/web/core/modules/layout_builder/tests/src/FunctionalJavascript/FieldBlockTest.php index f2ecd52b3..bd4d1843c 100644 --- a/web/core/modules/layout_builder/tests/src/FunctionalJavascript/FieldBlockTest.php +++ b/web/core/modules/layout_builder/tests/src/FunctionalJavascript/FieldBlockTest.php @@ -16,7 +16,14 @@ class FieldBlockTest extends WebDriverTestBase { /** * {@inheritdoc} */ - public static $modules = ['block', 'datetime', 'layout_builder', 'user']; + protected static $modules = [ + 'block', + 'datetime', + 'layout_builder', + 'user', + // See \Drupal\layout_builder_fieldblock_test\Plugin\Block\FieldBlock. + 'layout_builder_fieldblock_test', + ]; /** * {@inheritdoc} @@ -67,7 +74,7 @@ class FieldBlockTest extends WebDriverTestBase { $assert_session->pageTextNotContains('Initial email'); $assert_session->pageTextContains('Date field'); - $block_url = 'admin/structure/block/add/field_block%3Auser%3Auser%3Afield_date/classy'; + $block_url = 'admin/structure/block/add/field_block_test%3Auser%3Auser%3Afield_date/classy'; $assert_session->linkByHrefExists($block_url); $this->drupalGet($block_url); diff --git a/web/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockPrivateFilesTest.php b/web/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockPrivateFilesTest.php index 05892bd31..a94be8ba1 100644 --- a/web/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockPrivateFilesTest.php +++ b/web/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockPrivateFilesTest.php @@ -192,8 +192,8 @@ class InlineBlockPrivateFilesTest extends InlineBlockTestBase { $page = $this->getSession()->getPage(); $page->clickLink('Add Block'); $assert_session->assertWaitOnAjaxRequest(); - $this->assertNotEmpty($assert_session->waitForElementVisible('css', '.block-categories details:contains(Create new block)')); - $this->clickLink('Basic block'); + $this->assertNotEmpty($assert_session->waitForLink('Create custom block')); + $this->clickLink('Create custom block'); $assert_session->assertWaitOnAjaxRequest(); $assert_session->fieldValueEquals('Title', ''); $page->findField('Title')->setValue($title); diff --git a/web/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockTest.php b/web/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockTest.php index 9fdc8fdd3..4f463192d 100644 --- a/web/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockTest.php +++ b/web/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockTest.php @@ -428,4 +428,74 @@ class InlineBlockTest extends InlineBlockTestBase { $assert_session->pageTextNotContains('You are not authorized to access this page'); } + /** + * Tests the workflow for adding an inline block depending on number of types. + * + * @throws \Behat\Mink\Exception\ElementNotFoundException + * @throws \Behat\Mink\Exception\ExpectationException + */ + public function testAddWorkFlow() { + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + $type_storage = $this->container->get('entity_type.manager')->getStorage('block_content_type'); + foreach ($type_storage->loadByProperties() as $type) { + $type->delete(); + } + + $this->drupalLogin($this->drupalCreateUser([ + 'access contextual links', + 'configure any layout', + 'administer node display', + 'administer node fields', + ])); + + // Enable layout builder and overrides. + $this->drupalPostForm( + static::FIELD_UI_PREFIX . '/display/default', + ['layout[enabled]' => TRUE, 'layout[allow_custom]' => TRUE], + 'Save' + ); + + $layout_default_path = 'admin/structure/types/manage/bundle_with_section_field/display-layout/default'; + $this->drupalGet($layout_default_path); + // Add a basic block with the body field set. + $page->clickLink('Add Block'); + $assert_session->assertWaitOnAjaxRequest(); + // Confirm that with no block content types the link does not appear. + $assert_session->linkNotExists('Create custom block'); + + $this->createBlockContentType('basic', 'Basic block'); + + $this->drupalGet($layout_default_path); + // Add a basic block with the body field set. + $page->clickLink('Add Block'); + $assert_session->assertWaitOnAjaxRequest(); + // Confirm with only 1 type the "Create custom block" link goes directly t + // block add form. + $assert_session->linkNotExists('Basic block'); + $this->clickLink('Create custom block'); + $assert_session->assertWaitOnAjaxRequest(); + $assert_session->fieldExists('Title'); + + $this->createBlockContentType('advanced', 'Advanced block'); + + $this->drupalGet($layout_default_path); + // Add a basic block with the body field set. + $page->clickLink('Add Block'); + // Confirm that, when more than 1 type exists, "Create custom block" shows a + // list of block types. + $assert_session->assertWaitOnAjaxRequest(); + $assert_session->linkNotExists('Basic block'); + $assert_session->linkNotExists('Advanced block'); + $this->clickLink('Create custom block'); + $assert_session->assertWaitOnAjaxRequest(); + $assert_session->fieldNotExists('Title'); + $assert_session->linkExists('Basic block'); + $assert_session->linkExists('Advanced block'); + + $this->clickLink('Advanced block'); + $assert_session->assertWaitOnAjaxRequest(); + $assert_session->fieldExists('Title'); + } + } diff --git a/web/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockTestBase.php b/web/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockTestBase.php index 6c99c6c39..2b781766a 100644 --- a/web/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockTestBase.php +++ b/web/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockTestBase.php @@ -71,13 +71,7 @@ abstract class InlineBlockTestBase extends WebDriverTestBase { ], ], ]); - $bundle = BlockContentType::create([ - 'id' => 'basic', - 'label' => 'Basic block', - 'revision' => 1, - ]); - $bundle->save(); - block_content_add_body_field($bundle->id()); + $this->createBlockContentType('basic', 'Basic block'); $this->blockStorage = $this->container->get('entity_type.manager')->getStorage('block_content'); } @@ -146,8 +140,8 @@ abstract class InlineBlockTestBase extends WebDriverTestBase { $page = $this->getSession()->getPage(); $page->clickLink('Add Block'); $assert_session->assertWaitOnAjaxRequest(); - $this->assertNotEmpty($assert_session->waitForElementVisible('css', '.block-categories details:contains(Create new block)')); - $this->clickLink('Basic block'); + $this->assertNotEmpty($assert_session->waitForLink('Create custom block')); + $this->clickLink('Create custom block'); $assert_session->assertWaitOnAjaxRequest(); $textarea = $assert_session->waitForElement('css', '[name="settings[block_form][body][0][value]"]'); $this->assertNotEmpty($textarea); @@ -219,4 +213,22 @@ abstract class InlineBlockTestBase extends WebDriverTestBase { } } + /** + * Creates a block content type. + * + * @param string $id + * The block type id. + * @param string $label + * The block type label. + */ + protected function createBlockContentType($id, $label) { + $bundle = BlockContentType::create([ + 'id' => $id, + 'label' => $label, + 'revision' => 1, + ]); + $bundle->save(); + block_content_add_body_field($bundle->id()); + } + } diff --git a/web/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderUiTest.php b/web/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderUiTest.php new file mode 100644 index 000000000..c5510e9d5 --- /dev/null +++ b/web/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderUiTest.php @@ -0,0 +1,94 @@ +drupalPlaceBlock('local_tasks_block'); + + $this->createContentType(['type' => 'bundle_with_section_field']); + + $this->drupalLogin($this->drupalCreateUser([ + 'configure any layout', + 'administer node display', + 'administer node fields', + ])); + } + + /** + * Tests the message indicating unsaved changes. + */ + public function testUnsavedChangesMessage() { + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + + // Enable layout builder. + $this->drupalPostForm( + static::FIELD_UI_PREFIX . '/display/default', + ['layout[enabled]' => TRUE], + 'Save' + ); + + // Make and then cancel changes. + $this->assertModifiedLayout(static::FIELD_UI_PREFIX . '/display-layout/default'); + $page->clickLink('Cancel Layout'); + $assert_session->pageTextNotContains('You have unsaved changes.'); + + // Make and then save changes. + $this->assertModifiedLayout(static::FIELD_UI_PREFIX . '/display-layout/default'); + $page->clickLink('Save Layout'); + $assert_session->pageTextNotContains('You have unsaved changes.'); + } + + /** + * Asserts that modifying a layout works as expected. + * + * @param string $path + * The path to a Layout Builder UI page. + */ + protected function assertModifiedLayout($path) { + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + + $this->drupalGet($path); + $page->clickLink('Add Section'); + $assert_session->assertWaitOnAjaxRequest(); + $assert_session->pageTextNotContains('You have unsaved changes.'); + $page->clickLink('One column'); + $assert_session->assertWaitOnAjaxRequest(); + $assert_session->pageTextContainsOnce('You have unsaved changes.'); + + // Reload the page. + $this->drupalGet($path); + $assert_session->pageTextContainsOnce('You have unsaved changes.'); + } + +} diff --git a/web/core/modules/layout_builder/tests/src/Kernel/FieldBlockTest.php b/web/core/modules/layout_builder/tests/src/Kernel/FieldBlockTest.php index d5246a0c2..aaeb4ca81 100644 --- a/web/core/modules/layout_builder/tests/src/Kernel/FieldBlockTest.php +++ b/web/core/modules/layout_builder/tests/src/Kernel/FieldBlockTest.php @@ -230,11 +230,10 @@ class FieldBlockTest extends EntityKernelTestBase { * @covers ::build * @dataProvider providerTestBuild */ - public function testBuild(PromiseInterface $promise, $in_preview, $expected_markup, $log_message = '', $log_arguments = []) { + public function testBuild(PromiseInterface $promise, $expected_markup, $log_message = '', $log_arguments = []) { $entity = $this->prophesize(FieldableEntityInterface::class); $field = $this->prophesize(FieldItemListInterface::class); $entity->get('the_field_name')->willReturn($field->reveal()); - $entity->in_preview = $in_preview; $field->view(Argument::type('array'))->will($promise); $field_definition = $this->prophesize(FieldDefinitionInterface::class); @@ -269,40 +268,20 @@ class FieldBlockTest extends EntityKernelTestBase { */ public function providerTestBuild() { $data = []; - $data['array, no preview'] = [ + $data['array'] = [ new ReturnPromise([['content' => ['#markup' => 'The field value']]]), - FALSE, - 'The field value', - ]; - $data['array, preview'] = [ - new ReturnPromise([['content' => ['#markup' => 'The field value']]]), - TRUE, 'The field value', ]; - $data['empty array, no preview'] = [ + $data['empty array'] = [ new ReturnPromise([[]]), - FALSE, '', ]; - $data['empty array, preview'] = [ - new ReturnPromise([[]]), - TRUE, - 'Placeholder for the "The Field Label" field', - ]; - $data['exception, no preview'] = [ + $data['exception'] = [ new ThrowPromise(new \Exception('The exception message')), - FALSE, '', 'The field "%field" failed to render with the error of "%error".', ['%field' => 'the_field_name', '%error' => 'The exception message'], ]; - $data['exception, preview'] = [ - new ThrowPromise(new \Exception('The exception message')), - TRUE, - 'Placeholder for the "The Field Label" field', - 'The field "%field" failed to render with the error of "%error".', - ['%field' => 'the_field_name', '%error' => 'The exception message'], - ]; return $data; } diff --git a/web/core/modules/layout_builder/tests/src/Kernel/LayoutBuilderFieldLayoutCompatibilityTest.php b/web/core/modules/layout_builder/tests/src/Kernel/LayoutBuilderFieldLayoutCompatibilityTest.php index 57dacb5b1..12fd813f4 100644 --- a/web/core/modules/layout_builder/tests/src/Kernel/LayoutBuilderFieldLayoutCompatibilityTest.php +++ b/web/core/modules/layout_builder/tests/src/Kernel/LayoutBuilderFieldLayoutCompatibilityTest.php @@ -2,6 +2,7 @@ namespace Drupal\Tests\layout_builder\Kernel; +use Drupal\layout_builder\Plugin\SectionStorage\OverridesSectionStorage; use Drupal\layout_builder\Section; /** @@ -56,7 +57,7 @@ class LayoutBuilderFieldLayoutCompatibilityTest extends LayoutBuilderCompatibili // Add a layout override. $this->enableOverrides(); /** @var \Drupal\layout_builder\SectionStorageInterface $field_list */ - $field_list = $this->entity->get('layout_builder__layout'); + $field_list = $this->entity->get(OverridesSectionStorage::FIELD_NAME); $field_list->appendSection(new Section('layout_onecol')); $this->entity->save(); diff --git a/web/core/modules/layout_builder/tests/src/Kernel/LayoutBuilderInstallTest.php b/web/core/modules/layout_builder/tests/src/Kernel/LayoutBuilderInstallTest.php index aa1b9942c..a3ae736da 100644 --- a/web/core/modules/layout_builder/tests/src/Kernel/LayoutBuilderInstallTest.php +++ b/web/core/modules/layout_builder/tests/src/Kernel/LayoutBuilderInstallTest.php @@ -4,6 +4,7 @@ namespace Drupal\Tests\layout_builder\Kernel; use Drupal\field\Entity\FieldConfig; use Drupal\field\Entity\FieldStorageConfig; +use Drupal\layout_builder\Plugin\SectionStorage\OverridesSectionStorage; use Drupal\layout_builder\Section; /** @@ -35,7 +36,7 @@ class LayoutBuilderInstallTest extends LayoutBuilderCompatibilityTestBase { // Add a layout override. $this->enableOverrides(); $this->entity = $this->reloadEntity($this->entity); - $this->entity->get('layout_builder__layout')->appendSection(new Section('layout_onecol')); + $this->entity->get(OverridesSectionStorage::FIELD_NAME)->appendSection(new Section('layout_onecol')); $this->entity->save(); // The rendered entity has now changed. The non-configurable field is shown @@ -50,7 +51,7 @@ class LayoutBuilderInstallTest extends LayoutBuilderCompatibilityTestBase { $this->assertNotEmpty($this->cssSelect('.layout--onecol')); // Removing the layout restores the original rendering of the entity. - $this->entity->get('layout_builder__layout')->removeSection(0); + $this->entity->get(OverridesSectionStorage::FIELD_NAME)->removeSection(0); $this->entity->save(); $this->assertFieldAttributes($this->entity, $expected_fields); diff --git a/web/core/modules/layout_builder/tests/src/Kernel/LayoutSectionItemListTest.php b/web/core/modules/layout_builder/tests/src/Kernel/LayoutSectionItemListTest.php index 5bd354675..4231530fd 100644 --- a/web/core/modules/layout_builder/tests/src/Kernel/LayoutSectionItemListTest.php +++ b/web/core/modules/layout_builder/tests/src/Kernel/LayoutSectionItemListTest.php @@ -4,6 +4,7 @@ namespace Drupal\Tests\layout_builder\Kernel; use Drupal\entity_test\Entity\EntityTestBaseFieldDisplay; use Drupal\layout_builder\Entity\LayoutBuilderEntityViewDisplay; +use Drupal\layout_builder\Plugin\SectionStorage\OverridesSectionStorage; /** * Tests the field type for Layout Sections. @@ -42,10 +43,10 @@ class LayoutSectionItemListTest extends SectionStorageTestBase { }, $section_data); $entity = EntityTestBaseFieldDisplay::create([ 'name' => 'The test entity', - 'layout_builder__layout' => $section_data, + OverridesSectionStorage::FIELD_NAME => $section_data, ]); $entity->save(); - return $entity->get('layout_builder__layout'); + return $entity->get(OverridesSectionStorage::FIELD_NAME); } } diff --git a/web/core/modules/layout_builder/tests/src/Kernel/SectionStorageTestBase.php b/web/core/modules/layout_builder/tests/src/Kernel/SectionStorageTestBase.php index 187997763..6c54d6c9e 100644 --- a/web/core/modules/layout_builder/tests/src/Kernel/SectionStorageTestBase.php +++ b/web/core/modules/layout_builder/tests/src/Kernel/SectionStorageTestBase.php @@ -137,6 +137,17 @@ abstract class SectionStorageTestBase extends EntityKernelTestBase { $this->assertSections($expected); } + /** + * Tests __clone(). + */ + public function testClone() { + $this->assertSame([], $this->sectionStorage->getSection(0)->getLayoutSettings()); + + $new_section_storage = clone $this->sectionStorage; + $new_section_storage->getSection(0)->setLayoutSettings(['asdf' => 'qwer']); + $this->assertSame([], $this->sectionStorage->getSection(0)->getLayoutSettings()); + } + /** * Asserts that the field list has the expected sections. * diff --git a/web/core/modules/layout_builder/tests/src/Unit/LayoutTempstoreRepositoryTest.php b/web/core/modules/layout_builder/tests/src/Unit/LayoutTempstoreRepositoryTest.php index 0c74d0176..fb46e608a 100644 --- a/web/core/modules/layout_builder/tests/src/Unit/LayoutTempstoreRepositoryTest.php +++ b/web/core/modules/layout_builder/tests/src/Unit/LayoutTempstoreRepositoryTest.php @@ -16,6 +16,7 @@ class LayoutTempstoreRepositoryTest extends UnitTestCase { /** * @covers ::get + * @covers ::has */ public function testGetEmptyTempstore() { $section_storage = $this->prophesize(SectionStorageInterface::class); @@ -30,12 +31,15 @@ class LayoutTempstoreRepositoryTest extends UnitTestCase { $repository = new LayoutTempstoreRepository($tempstore_factory->reveal()); + $this->assertFalse($repository->has($section_storage->reveal())); + $result = $repository->get($section_storage->reveal()); $this->assertSame($section_storage->reveal(), $result); } /** * @covers ::get + * @covers ::has */ public function testGetLoadedTempstore() { $section_storage = $this->prophesize(SectionStorageInterface::class); @@ -50,6 +54,8 @@ class LayoutTempstoreRepositoryTest extends UnitTestCase { $repository = new LayoutTempstoreRepository($tempstore_factory->reveal()); + $this->assertTrue($repository->has($section_storage->reveal())); + $result = $repository->get($section_storage->reveal()); $this->assertSame($tempstore_section_storage->reveal(), $result); $this->assertNotSame($section_storage->reveal(), $result); diff --git a/web/core/modules/layout_builder/tests/src/Unit/OverridesSectionStorageTest.php b/web/core/modules/layout_builder/tests/src/Unit/OverridesSectionStorageTest.php index 110ec2021..3691fb6c7 100644 --- a/web/core/modules/layout_builder/tests/src/Unit/OverridesSectionStorageTest.php +++ b/web/core/modules/layout_builder/tests/src/Unit/OverridesSectionStorageTest.php @@ -112,13 +112,13 @@ class OverridesSectionStorageTest extends UnitTestCase { $entity_storage = $this->prophesize(EntityStorageInterface::class); $entity_without_layout = $this->prophesize(FieldableEntityInterface::class); - $entity_without_layout->hasField('layout_builder__layout')->willReturn(FALSE); - $entity_without_layout->get('layout_builder__layout')->shouldNotBeCalled(); + $entity_without_layout->hasField(OverridesSectionStorage::FIELD_NAME)->willReturn(FALSE); + $entity_without_layout->get(OverridesSectionStorage::FIELD_NAME)->shouldNotBeCalled(); $entity_storage->load('entity_without_layout')->willReturn($entity_without_layout->reveal()); $entity_with_layout = $this->prophesize(FieldableEntityInterface::class); - $entity_with_layout->hasField('layout_builder__layout')->willReturn(TRUE); - $entity_with_layout->get('layout_builder__layout')->willReturn('the_return_value'); + $entity_with_layout->hasField(OverridesSectionStorage::FIELD_NAME)->willReturn(TRUE); + $entity_with_layout->get(OverridesSectionStorage::FIELD_NAME)->willReturn('the_return_value'); $entity_storage->load('entity_with_layout')->willReturn($entity_with_layout->reveal()); $this->entityTypeManager->getStorage($expected_entity_type_id)->willReturn($entity_storage->reveal()); diff --git a/web/core/modules/media/src/Entity/Media.php b/web/core/modules/media/src/Entity/Media.php index c21821077..d68a0a451 100644 --- a/web/core/modules/media/src/Entity/Media.php +++ b/web/core/modules/media/src/Entity/Media.php @@ -185,20 +185,11 @@ class Media extends EditorialContentEntityBase implements MediaInterface { // Set the thumbnail alt. $media_source = $this->getSource(); $plugin_definition = $media_source->getPluginDefinition(); + + $this->thumbnail->alt = ''; if (!empty($plugin_definition['thumbnail_alt_metadata_attribute'])) { $this->thumbnail->alt = $media_source->getMetadata($this, $plugin_definition['thumbnail_alt_metadata_attribute']); } - else { - $this->thumbnail->alt = $this->t('Thumbnail', [], ['langcode' => $this->langcode->value]); - } - - // Set the thumbnail title. - if (!empty($plugin_definition['thumbnail_title_metadata_attribute'])) { - $this->thumbnail->title = $media_source->getMetadata($this, $plugin_definition['thumbnail_title_metadata_attribute']); - } - else { - $this->thumbnail->title = $this->label(); - } return $this; } diff --git a/web/core/modules/media/src/OEmbed/ResourceFetcher.php b/web/core/modules/media/src/OEmbed/ResourceFetcher.php index 0c210878f..e58e7e26a 100644 --- a/web/core/modules/media/src/OEmbed/ResourceFetcher.php +++ b/web/core/modules/media/src/OEmbed/ResourceFetcher.php @@ -7,7 +7,6 @@ use Drupal\Core\Cache\CacheBackendInterface; use Drupal\Core\Cache\UseCacheBackendTrait; use GuzzleHttp\ClientInterface; use GuzzleHttp\Exception\RequestException; -use Symfony\Component\Serializer\Encoder\XmlEncoder; /** * Fetches and caches oEmbed resources. @@ -69,8 +68,7 @@ class ResourceFetcher implements ResourceFetcherInterface { $content = (string) $response->getBody(); if (strstr($format, 'text/xml') || strstr($format, 'application/xml')) { - $encoder = new XmlEncoder(); - $data = $encoder->decode($content, 'xml'); + $data = $this->parseResourceXml($content, $url); } elseif (strstr($format, 'text/javascript') || strstr($format, 'application/json')) { $data = Json::decode($content); @@ -194,4 +192,42 @@ class ResourceFetcher implements ResourceFetcherInterface { } } + /** + * Parses XML resource data. + * + * @param string $data + * The raw XML for the resource. + * @param string $url + * The resource URL. + * + * @return array + * The parsed resource data. + * + * @throws \Drupal\media\OEmbed\ResourceException + * If the resource data could not be parsed. + */ + protected function parseResourceXml($data, $url) { + // Enable userspace error handling. + $was_using_internal_errors = libxml_use_internal_errors(TRUE); + libxml_clear_errors(); + + $content = simplexml_load_string($data, 'SimpleXMLElement', LIBXML_NOCDATA); + // Restore the previous error handling behavior. + libxml_use_internal_errors($was_using_internal_errors); + + $error = libxml_get_last_error(); + if ($error) { + libxml_clear_errors(); + throw new ResourceException($error->message, $url); + } + elseif ($content === FALSE) { + throw new ResourceException('The fetched resource could not be parsed.', $url); + } + + // Convert XML to JSON so that the parsed resource has a consistent array + // structure, regardless of any XML attributes or quirks of the XML parser. + $data = Json::encode($content); + return Json::decode($data); + } + } diff --git a/web/core/modules/media/src/Plugin/media/Source/Image.php b/web/core/modules/media/src/Plugin/media/Source/Image.php index a83a5144d..34b565c05 100644 --- a/web/core/modules/media/src/Plugin/media/Source/Image.php +++ b/web/core/modules/media/src/Plugin/media/Source/Image.php @@ -22,7 +22,8 @@ use Symfony\Component\DependencyInjection\ContainerInterface; * label = @Translation("Image"), * description = @Translation("Use local images for reusable media."), * allowed_field_types = {"image"}, - * default_thumbnail_filename = "no-thumbnail.png" + * default_thumbnail_filename = "no-thumbnail.png", + * thumbnail_alt_metadata_attribute = "thumbnail_alt_value" * ) */ class Image extends File { @@ -138,6 +139,9 @@ class Image extends File { case 'thumbnail_uri': return $uri; + + case 'thumbnail_alt_value': + return $media->get($this->configuration['source_field'])->alt ?: parent::getMetadata($media, $name); } return parent::getMetadata($media, $name); diff --git a/web/core/modules/media/tests/fixtures/oembed/video_collegehumor.xml b/web/core/modules/media/tests/fixtures/oembed/video_collegehumor.xml index 696b5bf84..9c0b08bfc 100644 --- a/web/core/modules/media/tests/fixtures/oembed/video_collegehumor.xml +++ b/web/core/modules/media/tests/fixtures/oembed/video_collegehumor.xml @@ -1,7 +1,7 @@ video - 1.0 + 1.0 Let's Not Get a Drink Sometime CollegeHumor diff --git a/web/core/modules/media/tests/src/Functional/Rest/MediaResourceTestBase.php b/web/core/modules/media/tests/src/Functional/Rest/MediaResourceTestBase.php index 85e4e5d73..de352e001 100644 --- a/web/core/modules/media/tests/src/Functional/Rest/MediaResourceTestBase.php +++ b/web/core/modules/media/tests/src/Functional/Rest/MediaResourceTestBase.php @@ -167,13 +167,13 @@ abstract class MediaResourceTestBase extends EntityResourceTestBase { ], 'thumbnail' => [ [ - 'alt' => 'Thumbnail', + 'alt' => '', 'width' => 180, 'height' => 180, 'target_id' => (int) $thumbnail->id(), 'target_type' => 'file', 'target_uuid' => $thumbnail->uuid(), - 'title' => 'Llama', + 'title' => NULL, 'url' => $thumbnail->url(), ], ], diff --git a/web/core/modules/media/tests/src/FunctionalJavascript/MediaSourceImageTest.php b/web/core/modules/media/tests/src/FunctionalJavascript/MediaSourceImageTest.php index faf58c1a3..cb013805a 100644 --- a/web/core/modules/media/tests/src/FunctionalJavascript/MediaSourceImageTest.php +++ b/web/core/modules/media/tests/src/FunctionalJavascript/MediaSourceImageTest.php @@ -59,6 +59,8 @@ class MediaSourceImageTest extends MediaSourceTestBase { // Make sure the thumbnail is displayed from uploaded image. $assert_session->elementAttributeContains('css', '.image-style-thumbnail', 'src', 'example_1.jpeg'); + // Ensure the thumbnail has the correct alt attribute. + $assert_session->elementAttributeContains('css', '.image-style-thumbnail', 'alt', 'Image Alt Text 1'); // Load the media and check that all fields are properly populated. $media = Media::load(1); diff --git a/web/core/modules/media/tests/src/Kernel/MediaSourceTest.php b/web/core/modules/media/tests/src/Kernel/MediaSourceTest.php index 79cb7b414..ecf177850 100644 --- a/web/core/modules/media/tests/src/Kernel/MediaSourceTest.php +++ b/web/core/modules/media/tests/src/Kernel/MediaSourceTest.php @@ -40,7 +40,6 @@ class MediaSourceTest extends MediaKernelTestBase { 'value' => 'Snowball', ], 'thumbnail_uri' => [ - 'title' => 'Thumbnail', 'value' => 'public://TheSisko.png', ], ]); @@ -230,7 +229,7 @@ class MediaSourceTest extends MediaKernelTestBase { // Save a media item and make sure thumbnail was added. \Drupal::state()->set('media_source_test_attributes', [ - 'thumbnail_uri' => ['title' => 'Thumbnail', 'value' => 'public://thumbnail1.jpg'], + 'thumbnail_uri' => ['value' => 'public://thumbnail1.jpg'], ]); /** @var \Drupal\media\MediaInterface $media */ $media = Media::create([ @@ -242,45 +241,46 @@ class MediaSourceTest extends MediaKernelTestBase { $this->assertSame('public://thumbnail1.jpg', $media_source->getMetadata($media, 'thumbnail_uri'), 'Value of the thumbnail metadata attribute is not correct.'); $media->save(); $this->assertSame('public://thumbnail1.jpg', $media->thumbnail->entity->getFileUri(), 'Thumbnail was not added to the media item.'); - $this->assertSame('Mr. Jones', $media->thumbnail->title, 'Title text was not set on the thumbnail.'); - $this->assertEquals('Thumbnail', $media->thumbnail->alt, 'Alt text was not set on the thumbnail.'); + // We expect the title not to be present on the Thumbnail. + $this->assertEmpty($media->thumbnail->title); + $this->assertSame('', $media->thumbnail->alt); // Now change the metadata attribute and make sure that the thumbnail stays // the same. \Drupal::state()->set('media_source_test_attributes', [ - 'thumbnail_uri' => ['title' => 'Thumbnail', 'value' => 'public://thumbnail2.jpg'], + 'thumbnail_uri' => ['value' => 'public://thumbnail2.jpg'], ]); $this->assertSame('public://thumbnail2.jpg', $media_source->getMetadata($media, 'thumbnail_uri'), 'Value of the thumbnail metadata attribute is not correct.'); $media->save(); $this->assertSame('public://thumbnail1.jpg', $media->thumbnail->entity->getFileUri(), 'Thumbnail was not preserved.'); - $this->assertSame('Mr. Jones', $media->thumbnail->title, 'Title text was not set on the thumbnail.'); - $this->assertEquals('Thumbnail', $media->thumbnail->alt, 'Alt text was not set on the thumbnail.'); + $this->assertEmpty($media->thumbnail->title); + $this->assertSame('', $media->thumbnail->alt); // Remove the thumbnail and make sure that it is auto-updated on save. $media->thumbnail->target_id = NULL; $this->assertSame('public://thumbnail2.jpg', $media_source->getMetadata($media, 'thumbnail_uri'), 'Value of the thumbnail metadata attribute is not correct.'); $media->save(); $this->assertSame('public://thumbnail2.jpg', $media->thumbnail->entity->getFileUri(), 'New thumbnail was not added to the media item.'); - $this->assertSame('Mr. Jones', $media->thumbnail->title, 'Title text was not set on the thumbnail.'); - $this->assertEquals('Thumbnail', $media->thumbnail->alt, 'Alt text was not set on the thumbnail.'); + $this->assertEmpty($media->thumbnail->title); + $this->assertSame('', $media->thumbnail->alt); // Change the metadata attribute again, change the source field value too // and make sure that the thumbnail updates. \Drupal::state()->set('media_source_test_attributes', [ - 'thumbnail_uri' => ['title' => 'Thumbnail', 'value' => 'public://thumbnail1.jpg'], + 'thumbnail_uri' => ['value' => 'public://thumbnail1.jpg'], ]); $media->field_media_test->value = 'some_new_value'; $this->assertSame('public://thumbnail1.jpg', $media_source->getMetadata($media, 'thumbnail_uri'), 'Value of the thumbnail metadata attribute is not correct.'); $media->save(); $this->assertSame('public://thumbnail1.jpg', $media->thumbnail->entity->getFileUri(), 'New thumbnail was not added to the media item.'); - $this->assertSame('Mr. Jones', $media->thumbnail->title, 'Title text was not set on the thumbnail.'); - $this->assertEquals('Thumbnail', $media->thumbnail->alt, 'Alt text was not set on the thumbnail.'); + $this->assertEmpty($media->thumbnail->title); + $this->assertSame('', $media->thumbnail->alt); // Change the thumbnail metadata attribute and make sure that the thumbnail // is set correctly. \Drupal::state()->set('media_source_test_attributes', [ - 'thumbnail_uri' => ['title' => 'Should not be used', 'value' => 'public://thumbnail1.jpg'], - 'alternative_thumbnail_uri' => ['title' => 'Should be used', 'value' => 'public://thumbnail2.jpg'], + 'thumbnail_uri' => ['value' => 'public://thumbnail1.jpg'], + 'alternative_thumbnail_uri' => ['value' => 'public://thumbnail2.jpg'], ]); \Drupal::state()->set('media_source_test_definition', ['thumbnail_uri_metadata_attribute' => 'alternative_thumbnail_uri']); $media = Media::create([ @@ -293,14 +293,14 @@ class MediaSourceTest extends MediaKernelTestBase { $this->assertSame('public://thumbnail2.jpg', $media_source->getMetadata($media, 'alternative_thumbnail_uri'), 'Value of the thumbnail metadata attribute is not correct.'); $media->save(); $this->assertSame('public://thumbnail2.jpg', $media->thumbnail->entity->getFileUri(), 'Correct metadata attribute was not used for the thumbnail.'); - $this->assertSame('Mr. Jones', $media->thumbnail->title, 'Title text was not set on the thumbnail.'); - $this->assertEquals('Thumbnail', $media->thumbnail->alt, 'Alt text was not set on the thumbnail.'); + $this->assertEmpty($media->thumbnail->title); + $this->assertSame('', $media->thumbnail->alt); // Enable queued thumbnails and make sure that the entity gets the default // thumbnail initially. \Drupal::state()->set('media_source_test_definition', []); \Drupal::state()->set('media_source_test_attributes', [ - 'thumbnail_uri' => ['title' => 'Should not be used', 'value' => 'public://thumbnail1.jpg'], + 'thumbnail_uri' => ['value' => 'public://thumbnail1.jpg'], ]); $this->testMediaType->setQueueThumbnailDownloadsStatus(TRUE)->save(); $media = Media::create([ @@ -311,8 +311,8 @@ class MediaSourceTest extends MediaKernelTestBase { $this->assertSame('public://thumbnail1.jpg', $media->getSource()->getMetadata($media, 'thumbnail_uri'), 'Value of the metadata attribute is not correct.'); $media->save(); $this->assertSame('public://media-icons/generic/generic.png', $media->thumbnail->entity->getFileUri(), 'Default thumbnail was not set initially.'); - $this->assertSame('Mr. Jones', $media->thumbnail->title, 'Title text was not set on the thumbnail.'); - $this->assertEquals('Thumbnail', $media->thumbnail->alt, 'Alt text was not set on the thumbnail.'); + $this->assertEmpty($media->thumbnail->title); + $this->assertSame('', $media->thumbnail->alt); // Process the queue item and make sure that the thumbnail was updated too. $queue_name = 'media_entity_thumbnail'; @@ -330,18 +330,15 @@ class MediaSourceTest extends MediaKernelTestBase { $media = Media::load($media->id()); $this->assertSame('public://thumbnail1.jpg', $media->thumbnail->entity->getFileUri(), 'Thumbnail was not updated by the queue.'); - $this->assertSame('Mr. Jones', $media->thumbnail->title, 'Title text was not set on the thumbnail.'); - $this->assertSame('Thumbnail', $media->thumbnail->alt, 'Alt text was not set on the thumbnail.'); + $this->assertEmpty($media->thumbnail->title); + $this->assertSame('', $media->thumbnail->alt); - // Set alt and title metadata attributes and make sure they are used for the - // thumbnail. + // Set the alt metadata attribute and make sure it's used for the thumbnail. \Drupal::state()->set('media_source_test_definition', [ 'thumbnail_alt_metadata_attribute' => 'alt', - 'thumbnail_title_metadata_attribute' => 'title', ]); \Drupal::state()->set('media_source_test_attributes', [ - 'alt' => ['title' => 'Alt text', 'value' => 'This will be alt.'], - 'title' => ['title' => 'Title text', 'value' => 'This will be title.'], + 'alt' => ['value' => 'This will be alt.'], ]); $media = Media::create([ 'bundle' => $this->testMediaType->id(), @@ -350,8 +347,8 @@ class MediaSourceTest extends MediaKernelTestBase { ]); $media->save(); $this->assertSame('Boxer', $media->getName(), 'Correct name was not set on the media item.'); - $this->assertSame('This will be title.', $media->thumbnail->title, 'Title text was not set on the thumbnail.'); - $this->assertSame('This will be alt.', $media->thumbnail->alt, 'Alt text was not set on the thumbnail.'); + $this->assertEmpty($media->thumbnail->title); + $this->assertSame('This will be alt.', $media->thumbnail->alt); } /** diff --git a/web/core/modules/menu_link_content/src/Plugin/Menu/MenuLinkContent.php b/web/core/modules/menu_link_content/src/Plugin/Menu/MenuLinkContent.php index 98e8cdfcb..1888eb121 100644 --- a/web/core/modules/menu_link_content/src/Plugin/Menu/MenuLinkContent.php +++ b/web/core/modules/menu_link_content/src/Plugin/Menu/MenuLinkContent.php @@ -198,7 +198,7 @@ class MenuLinkContent extends MenuLinkBase implements ContainerFactoryPluginInte * The menu link ID. */ protected function getUuid() { - $this->getDerivativeId(); + return $this->getDerivativeId(); } /** diff --git a/web/core/modules/menu_link_content/tests/src/Unit/MenuLinkPluginTest.php b/web/core/modules/menu_link_content/tests/src/Unit/MenuLinkPluginTest.php new file mode 100644 index 000000000..a6af5800c --- /dev/null +++ b/web/core/modules/menu_link_content/tests/src/Unit/MenuLinkPluginTest.php @@ -0,0 +1,31 @@ +prophesize(MenuLinkContent::class); + $menu_link_content_plugin->getDerivativeId()->willReturn('test_id'); + $menu_link_content_plugin = $menu_link_content_plugin->reveal(); + + $class = new \ReflectionClass(MenuLinkContent::class); + $instance_method = $class->getMethod('getUuid'); + $instance_method->setAccessible(TRUE); + + $this->assertEquals('test_id', $instance_method->invoke($menu_link_content_plugin)); + } + +} diff --git a/web/core/modules/migrate/src/Plugin/MigrationPluginManager.php b/web/core/modules/migrate/src/Plugin/MigrationPluginManager.php index dde8a9f0a..6d364e77f 100644 --- a/web/core/modules/migrate/src/Plugin/MigrationPluginManager.php +++ b/web/core/modules/migrate/src/Plugin/MigrationPluginManager.php @@ -208,7 +208,14 @@ class MigrationPluginManager extends DefaultPluginManager implements MigrationPl $migration->set('requirements', $required_dependency_graph[$migration_id]['paths']); } } - array_multisort($weights, SORT_DESC, SORT_NUMERIC, $migrations); + // Sort weights, labels, and keys in the same order as each other. + array_multisort( + // Use the numerical weight as the primary sort. + $weights, SORT_DESC, SORT_NUMERIC, + // When migrations have the same weight, sort them alphabetically by ID. + array_keys($migrations), SORT_ASC, SORT_NATURAL, + $migrations + ); return $migrations; } diff --git a/web/core/modules/migrate/src/Plugin/migrate/process/FormatDate.php b/web/core/modules/migrate/src/Plugin/migrate/process/FormatDate.php index 9448ca2ff..c9b75a9d7 100644 --- a/web/core/modules/migrate/src/Plugin/migrate/process/FormatDate.php +++ b/web/core/modules/migrate/src/Plugin/migrate/process/FormatDate.php @@ -82,7 +82,8 @@ use Drupal\migrate\Row; * @endcode * * If the source value was '2004-12-19T10:19:42-0600' the transformed value - * would be 2004-12-19T10:19:42. + * would be 2004-12-19T10:19:42. Set validate_format to false if your source + * value is '0000-00-00 00:00:00'. * * @see \DateTime::createFromFormat() * @see \Drupal\Component\Datetime\DateTimePlus::__construct() @@ -99,7 +100,7 @@ class FormatDate extends ProcessPluginBase { * {@inheritdoc} */ public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) { - if (empty($value)) { + if (empty($value) && $value !== '0' && $value !== 0) { return ''; } diff --git a/web/core/modules/migrate/src/Plugin/migrate/source/SourcePluginBase.php b/web/core/modules/migrate/src/Plugin/migrate/source/SourcePluginBase.php index 3dd7144b8..8267bac78 100644 --- a/web/core/modules/migrate/src/Plugin/migrate/source/SourcePluginBase.php +++ b/web/core/modules/migrate/src/Plugin/migrate/source/SourcePluginBase.php @@ -353,7 +353,7 @@ abstract class SourcePluginBase extends PluginBase implements MigrateSourceInter $row_data = $this->getIterator()->current() + $this->configuration; $this->fetchNextRow(); - $row = new Row($row_data, $this->migration->getSourcePlugin()->getIds(), $this->migration->getDestinationIds()); + $row = new Row($row_data, $this->getIds()); // Populate the source key for this row. $this->currentSourceIds = $row->getSourceIdValues(); diff --git a/web/core/modules/migrate/src/Plugin/migrate/source/SqlBase.php b/web/core/modules/migrate/src/Plugin/migrate/source/SqlBase.php index b9e8e731a..4bf846e85 100644 --- a/web/core/modules/migrate/src/Plugin/migrate/source/SqlBase.php +++ b/web/core/modules/migrate/src/Plugin/migrate/source/SqlBase.php @@ -108,6 +108,11 @@ abstract class SqlBase extends SourcePluginBase implements ContainerFactoryPlugi public function __construct(array $configuration, $plugin_id, $plugin_definition, MigrationInterface $migration, StateInterface $state) { parent::__construct($configuration, $plugin_id, $plugin_definition, $migration); $this->state = $state; + // If we are using high water, but haven't yet set a high water mark, skip + // joining the map table, as we want to get all available records. + if ($this->getHighWaterProperty() && $this->getHighWater() === NULL) { + $this->configuration['ignore_map'] = TRUE; + } } /** @@ -322,7 +327,9 @@ abstract class SqlBase extends SourcePluginBase implements ContainerFactoryPlugi if ($this->getHighWaterProperty()) { $high_water_field = $this->getHighWaterField(); $high_water = $this->getHighWater(); - if ($high_water) { + // We check against NULL because 0 is an acceptable value for the high + // water mark. + if ($high_water !== NULL) { $conditions->condition($high_water_field, $high_water, '>'); $condition_added = TRUE; } diff --git a/web/core/modules/migrate/tests/src/Kernel/HighWaterTest.php b/web/core/modules/migrate/tests/src/Kernel/HighWaterTest.php index faffb6635..27f826603 100644 --- a/web/core/modules/migrate/tests/src/Kernel/HighWaterTest.php +++ b/web/core/modules/migrate/tests/src/Kernel/HighWaterTest.php @@ -136,6 +136,72 @@ class HighWaterTest extends MigrateTestBase { $this->assertNodeDoesNotExist('Item 3'); } + /** + * Tests that the high water value can be 0. + */ + public function testZeroHighwater() { + // Assert all of the nodes have been imported. + $this->assertNodeExists('Item 1'); + $this->assertNodeExists('Item 2'); + $this->assertNodeExists('Item 3'); + $migration = $this->container->get('plugin.manager.migration')->CreateInstance('high_water_test', []); + $source = $migration->getSourcePlugin(); + $source->rewind(); + $count = 0; + while ($source->valid()) { + $count++; + $source->next(); + } + + // Expect no rows as everything is below the high water mark. + $this->assertSame(0, $count); + + // Test resetting the high water mark to 0. + $this->container->get('keyvalue')->get('migrate:high_water')->set('high_water_test', 0); + $migration = $this->container->get('plugin.manager.migration')->CreateInstance('high_water_test', []); + $source = $migration->getSourcePlugin(); + $source->rewind(); + $count = 0; + while ($source->valid()) { + $count++; + $source->next(); + } + $this->assertSame(3, $count); + } + + /** + * Tests that deleting the high water value causes all rows to be reimported. + */ + public function testNullHighwater() { + // Assert all of the nodes have been imported. + $this->assertNodeExists('Item 1'); + $this->assertNodeExists('Item 2'); + $this->assertNodeExists('Item 3'); + $migration = $this->container->get('plugin.manager.migration')->CreateInstance('high_water_test', []); + $source = $migration->getSourcePlugin(); + $source->rewind(); + $count = 0; + while ($source->valid()) { + $count++; + $source->next(); + } + + // Expect no rows as everything is below the high water mark. + $this->assertSame(0, $count); + + // Test resetting the high water mark. + $this->container->get('keyvalue')->get('migrate:high_water')->delete('high_water_test'); + $migration = $this->container->get('plugin.manager.migration')->CreateInstance('high_water_test', []); + $source = $migration->getSourcePlugin(); + $source->rewind(); + $count = 0; + while ($source->valid()) { + $count++; + $source->next(); + } + $this->assertSame(3, $count); + } + /** * Tests high water property of SqlBase when rows marked for update. */ diff --git a/web/core/modules/migrate/tests/src/Kernel/MigrateEmbeddedDataTest.php b/web/core/modules/migrate/tests/src/Kernel/MigrateEmbeddedDataTest.php index 019ac42ab..7be474e01 100644 --- a/web/core/modules/migrate/tests/src/Kernel/MigrateEmbeddedDataTest.php +++ b/web/core/modules/migrate/tests/src/Kernel/MigrateEmbeddedDataTest.php @@ -45,6 +45,11 @@ class MigrateEmbeddedDataTest extends KernelTestBase { $results = []; /** @var \Drupal\migrate\Row $row */ foreach ($source as $row) { + // The plugin should not mark any rows as stubs. We need to use + // assertSame() here because assertFalse() will pass falsy values (e.g., + // empty arrays). + $this->assertSame(FALSE, $row->isStub()); + $data_row = $row->getSource(); // The "data" row returned by getSource() also includes all source // configuration - we remove it so we see only the data itself. diff --git a/web/core/modules/migrate/tests/src/Unit/process/FormatDateTest.php b/web/core/modules/migrate/tests/src/Unit/process/FormatDateTest.php index 994de3855..d11e95e4f 100644 --- a/web/core/modules/migrate/tests/src/Unit/process/FormatDateTest.php +++ b/web/core/modules/migrate/tests/src/Unit/process/FormatDateTest.php @@ -175,6 +175,42 @@ class FormatDateTest extends MigrateProcessTestCase { // converted from Australia/Sydney to America/Managua timezone. 'expected' => '2004-12-18 17:19:42 America/Managua', ], + 'integer_0' => [ + 'configuration' => [ + 'from_format' => 'U', + 'to_format' => 'Y-m-d', + ], + 'value' => 0, + 'expected' => '1970-01-01', + ], + 'string_0' => [ + 'configuration' => [ + 'from_format' => 'U', + 'to_format' => 'Y-m-d', + ], + 'value' => '0', + 'expected' => '1970-01-01', + ], + 'zeros' => [ + 'configuration' => [ + 'from_format' => 'Y-m-d H:i:s', + 'to_format' => 'Y-m-d H:i:s e', + 'settings' => ['validate_format' => FALSE], + ], + 'value' => '0000-00-00 00:00:00', + 'expected' => '-0001-11-30 00:00:00 Australia/Sydney', + ], + 'zeros_same_timezone' => [ + 'configuration' => [ + 'from_format' => 'Y-m-d H:i:s', + 'to_format' => 'Y-m-d H:i:s', + 'settings' => ['validate_format' => FALSE], + 'from_timezone' => 'UTC', + 'to_timezone' => 'UTC', + ], + 'value' => '0000-00-00 00:00:00', + 'expected' => '-0001-11-30 00:00:00', + ], ]; } diff --git a/web/core/modules/migrate_drupal/src/Plugin/migrate/source/DrupalSqlBase.php b/web/core/modules/migrate_drupal/src/Plugin/migrate/source/DrupalSqlBase.php index c8b44f894..15acb0213 100644 --- a/web/core/modules/migrate_drupal/src/Plugin/migrate/source/DrupalSqlBase.php +++ b/web/core/modules/migrate_drupal/src/Plugin/migrate/source/DrupalSqlBase.php @@ -102,6 +102,7 @@ abstract class DrupalSqlBase extends SqlBase implements ContainerFactoryPluginIn * {@inheritdoc} */ public function checkRequirements() { + parent::checkRequirements(); if ($this->pluginDefinition['requirements_met'] === TRUE) { if (isset($this->pluginDefinition['source_module'])) { if ($this->moduleExists($this->pluginDefinition['source_module'])) { @@ -114,7 +115,6 @@ abstract class DrupalSqlBase extends SqlBase implements ContainerFactoryPluginIn } } } - parent::checkRequirements(); } /** diff --git a/web/core/modules/migrate_drupal/tests/fixtures/drupal6.php b/web/core/modules/migrate_drupal/tests/fixtures/drupal6.php index 185a7b6a7..389a4df56 100644 --- a/web/core/modules/migrate_drupal/tests/fixtures/drupal6.php +++ b/web/core/modules/migrate_drupal/tests/fixtures/drupal6.php @@ -10071,6 +10071,30 @@ $connection->insert('i18n_strings') 'objectindex' => '0', 'format' => '0', )) +->values(array( + 'lid' => '1692', + 'objectid' => '14', + 'type' => 'term', + 'property' => 'name', + 'objectindex' => '14', + 'format' => '0', +)) +->values(array( + 'lid' => '1693', + 'objectid' => '15', + 'type' => 'term', + 'property' => 'name', + 'objectindex' => '15', + 'format' => '0', +)) +->values(array( + 'lid' => '1694', + 'objectid' => '14', + 'type' => 'term', + 'property' => 'description', + 'objectindex' => '14', + 'format' => '0', +)) ->execute(); $connection->schema()->createTable('i18n_variable', array( @@ -22642,6 +22666,27 @@ $connection->insert('locales_source') 'source' => 'White', 'version' => '1', )) +->values(array( + 'lid' => '1692', + 'location' => 'term:14:name', + 'textgroup' => 'taxonomy', + 'source' => 'Talos IV', + 'version' => '1', +)) +->values(array( + 'lid' => '1693', + 'location' => 'term:15:name', + 'textgroup' => 'taxonomy', + 'source' => 'Vulcan', + 'version' => '1', +)) +->values(array( + 'lid' => '1694', + 'location' => 'term:14:description', + 'textgroup' => 'taxonomy', + 'source' => 'The home of Captain Christopher Pike.', + 'version' => '1', +)) ->execute(); $connection->schema()->createTable('locales_target', array( @@ -27718,6 +27763,30 @@ $connection->insert('locales_target') 'plural' => '0', 'i18n_status' => '0', )) +->values(array( + 'lid' => '1672', + 'translation' => 'fr - Type', + 'language' => 'fr', + 'plid' => '0', + 'plural' => '0', + 'i18n_status' => '0', +)) +->values(array( + 'lid' => '1692', + 'translation' => 'fr - Talos IV', + 'language' => 'fr', + 'plid' => '0', + 'plural' => '0', + 'i18n_status' => '0', +)) +->values(array( + 'lid' => '1694', + 'translation' => 'fr - The home of Captain Christopher Pike.', + 'language' => 'fr', + 'plid' => '0', + 'plural' => '0', + 'i18n_status' => '0', +)) ->values(array( 'lid' => '66', 'translation' => 'zu - CCK - Aucune Intégration aux Vues', @@ -27846,6 +27915,30 @@ $connection->insert('locales_target') 'plural' => '0', 'i18n_status' => '0', )) +->values(array( + 'lid' => '1672', + 'translation' => 'zu - Type', + 'language' => 'zu', + 'plid' => '0', + 'plural' => '0', + 'i18n_status' => '0', +)) +->values(array( + 'lid' => '1693', + 'translation' => 'zu - Vulcan', + 'language' => 'zu', + 'plid' => '0', + 'plural' => '0', + 'i18n_status' => '0', +)) +->values(array( + 'lid' => '1694', + 'translation' => 'zu - The home of Captain Christopher Pike.', + 'language' => 'zu', + 'plid' => '0', + 'plural' => '0', + 'i18n_status' => '0', +)) ->execute(); $connection->schema()->createTable('menu_custom', array( @@ -40934,6 +41027,28 @@ $connection->insert('menu_router') 'weight' => '0', 'file' => 'sites/all/modules/i18n/i18n.admin.inc', )) +->values(array( + 'path' => 'admin/settings/language/i18n/variables', + 'load_functions' => '', + 'to_arg_functions' => '', + 'access_callback' => 'user_access', + 'access_arguments' => 'a:1:{i:0;s:29:"administer site configuration";}', + 'page_callback' => 'drupal_get_form', + 'page_arguments' => 'a:1:{i:0;s:25:"i18n_admin_variables_form";}', + 'fit' => '31', + 'number_parts' => '5', + 'tab_parent' => 'admin/settings/language/i18n', + 'tab_root' => 'admin/settings/language', + 'title' => 'Variables', + 'title_callback' => 't', + 'title_arguments' => '', + 'type' => '128', + 'block_callback' => '', + 'description' => 'Multilingual variables.', + 'position' => '', + 'weight' => '0', + 'file' => 'sites/all/modules/i18n/i18n.admin.inc', +)) ->values(array( 'path' => 'admin/settings/language/overview', 'load_functions' => '', @@ -47091,6 +47206,69 @@ $connection->insert('term_data') 'language' => '', 'trid' => '0', )) +->values(array( + 'tid' => '9', + 'vid' => '3', + 'name' => 'fr - term 4 of vocabulary 3', + 'description' => '', + 'weight' => '0', + 'language' => 'fr', + 'trid' => '1', +)) +->values(array( + 'tid' => '10', + 'vid' => '3', + 'name' => 'zu - term 4 of vocabulary 3', + 'description' => '', + 'weight' => '0', + 'language' => 'zu', + 'trid' => '1', +)) +->values(array( + 'tid' => '11', + 'vid' => '3', + 'name' => 'term 7 of vocabulary 3', + 'description' => '', + 'weight' => '0', + 'language' => 'en', + 'trid' => '2', +)) +->values(array( + 'tid' => '12', + 'vid' => '3', + 'name' => 'fr - term 7 of vocabulary 3', + 'description' => '', + 'weight' => '0', + 'language' => 'fr', + 'trid' => '2', +)) +->values(array( + 'tid' => '13', + 'vid' => '3', + 'name' => 'zu - term 7 of vocabulary 3', + 'description' => '', + 'weight' => '0', + 'language' => 'zu', + 'trid' => '2', +)) +->values(array( + 'tid' => '14', + 'vid' => '5', + 'name' => 'Talos IV', + 'description' => 'The home of Captain Christopher Pike.', + 'weight' => '0', + 'language' => '', + 'trid' => '0', +)) +->values(array( + 'tid' => '15', + 'vid' => '5', + 'name' => 'Vulcan', + 'description' => '', + 'weight' => '0', + 'language' => '', + 'trid' => '0', +)) ->execute(); $connection->schema()->createTable('term_hierarchy', array( @@ -47142,6 +47320,34 @@ $connection->insert('term_hierarchy') 'tid' => '8', 'parent' => '0', )) +->values(array( + 'tid' => '9', + 'parent' => '0', +)) +->values(array( + 'tid' => '10', + 'parent' => '0', +)) +->values(array( + 'tid' => '11', + 'parent' => '0', +)) +->values(array( + 'tid' => '12', + 'parent' => '0', +)) +->values(array( + 'tid' => '13', + 'parent' => '0', +)) +->values(array( + 'tid' => '14', + 'parent' => '0', +)) +->values(array( + 'tid' => '15', + 'parent' => '0', +)) ->values(array( 'tid' => '3', 'parent' => '2', @@ -48740,7 +48946,7 @@ $connection->insert('variable') )) ->values(array( 'name' => 'i18ntaxonomy_vocabulary', - 'value' => 'a:3:{i:1;s:1:"3";i:2;s:1:"2";i:3;s:1:"1";}', + 'value' => 'a:4:{i:1;s:1:"3";i:2;s:1:"2";i:3;s:1:"3";i:5;s:1:"1";}', )) ->values(array( 'name' => 'i18n_lock_node_article', diff --git a/web/core/modules/migrate_drupal/tests/fixtures/drupal7.php b/web/core/modules/migrate_drupal/tests/fixtures/drupal7.php index 3f5242e3c..9be6c119e 100644 --- a/web/core/modules/migrate_drupal/tests/fixtures/drupal7.php +++ b/web/core/modules/migrate_drupal/tests/fixtures/drupal7.php @@ -11,286 +11,6 @@ use Drupal\Core\Database\Database; $connection = Database::getConnection(); -$connection->schema()->createTable('i18n_block_language', array( - 'fields' => array( - 'module' => array( - 'type' => 'varchar', - 'not null' => TRUE, - 'length' => '64', - ), - 'delta' => array( - 'type' => 'varchar', - 'not null' => TRUE, - 'length' => '32', - ), - 'language' => array( - 'type' => 'varchar', - 'not null' => TRUE, - 'length' => '12', - 'default' => '', - ), - ), - 'primary key' => array( - 'module', - 'delta', - 'language', - ), - 'indexes' => array( - 'language' => array( - 'language', - ), - ), - 'mysql_character_set' => 'utf8', -)); - -$connection->schema()->createTable('i18n_string', array( - 'fields' => array( - 'lid' => array( - 'type' => 'int', - 'not null' => TRUE, - 'size' => 'normal', - 'default' => '0', - ), - 'textgroup' => array( - 'type' => 'varchar', - 'not null' => TRUE, - 'length' => '50', - 'default' => 'default', - ), - 'context' => array( - 'type' => 'varchar', - 'not null' => TRUE, - 'length' => '255', - 'default' => '', - ), - 'objectid' => array( - 'type' => 'varchar', - 'not null' => TRUE, - 'length' => '255', - 'default' => '', - ), - 'type' => array( - 'type' => 'varchar', - 'not null' => TRUE, - 'length' => '255', - 'default' => '', - ), - 'property' => array( - 'type' => 'varchar', - 'not null' => TRUE, - 'length' => '255', - 'default' => '', - ), - 'objectindex' => array( - 'type' => 'int', - 'not null' => TRUE, - 'size' => 'big', - 'default' => '0', - ), - 'format' => array( - 'type' => 'varchar', - 'not null' => FALSE, - 'length' => '255', - ), - ), - 'primary key' => array( - 'lid', - ), - 'indexes' => array( - 'group_context' => array( - 'textgroup', - array( - 'context', - '50', - ), - ), - ), - 'mysql_character_set' => 'utf8', -)); - -$connection->insert('i18n_string') -->fields(array( - 'lid', - 'textgroup', - 'context', - 'objectid', - 'type', - 'property', - 'objectindex', - 'format', -)) -->values(array( - 'lid' => '57', - 'textgroup' => 'blocks', - 'context' => 'block:1:title', - 'objectid' => '1', - 'type' => 'block', - 'property' => 'title', - 'objectindex' => '1', - 'format' => '', -)) -->values(array( - 'lid' => '60', - 'textgroup' => 'blocks', - 'context' => 'block:1:body', - 'objectid' => '1', - 'type' => 'block', - 'property' => 'body', - 'objectindex' => '1', - 'format' => 'filtered_html', -)) -->values(array( - 'lid' => '61', - 'textgroup' => 'node', - 'context' => 'type:article:name', - 'objectid' => 'article', - 'type' => 'type', - 'property' => 'name', - 'objectindex' => '0', - 'format' => '', -)) -->values(array( - 'lid' => '62', - 'textgroup' => 'node', - 'context' => 'type:article:title_label', - 'objectid' => 'article', - 'type' => 'type', - 'property' => 'title_label', - 'objectindex' => '0', - 'format' => '', -)) -->values(array( - 'lid' => '63', - 'textgroup' => 'node', - 'context' => 'type:article:description', - 'objectid' => 'article', - 'type' => 'type', - 'property' => 'description', - 'objectindex' => '0', - 'format' => '', -)) -->values(array( - 'lid' => '64', - 'textgroup' => 'node', - 'context' => 'type:article:help', - 'objectid' => 'article', - 'type' => 'type', - 'property' => 'help', - 'objectindex' => '0', - 'format' => '', -)) -->values(array( - 'lid' => '65', - 'textgroup' => 'node', - 'context' => 'type:book:name', - 'objectid' => 'book', - 'type' => 'type', - 'property' => 'name', - 'objectindex' => '0', - 'format' => '', -)) -->values(array( - 'lid' => '66', - 'textgroup' => 'node', - 'context' => 'type:book:title_label', - 'objectid' => 'book', - 'type' => 'type', - 'property' => 'title_label', - 'objectindex' => '0', - 'format' => '', -)) -->values(array( - 'lid' => '67', - 'textgroup' => 'node', - 'context' => 'type:book:description', - 'objectid' => 'book', - 'type' => 'type', - 'property' => 'description', - 'objectindex' => '0', - 'format' => '', -)) -->values(array( - 'lid' => '68', - 'textgroup' => 'node', - 'context' => 'type:page:name', - 'objectid' => 'page', - 'type' => 'type', - 'property' => 'name', - 'objectindex' => '0', - 'format' => '', -)) -->values(array( - 'lid' => '69', - 'textgroup' => 'node', - 'context' => 'type:page:title_label', - 'objectid' => 'page', - 'type' => 'type', - 'property' => 'title_label', - 'objectindex' => '0', - 'format' => '', -)) -->values(array( - 'lid' => '70', - 'textgroup' => 'node', - 'context' => 'type:page:description', - 'objectid' => 'page', - 'type' => 'type', - 'property' => 'description', - 'objectindex' => '0', - 'format' => '', -)) -->values(array( - 'lid' => '71', - 'textgroup' => 'node', - 'context' => 'type:page:help', - 'objectid' => 'page', - 'type' => 'type', - 'property' => 'help', - 'objectindex' => '0', - 'format' => '', -)) -->values(array( - 'lid' => '72', - 'textgroup' => 'node', - 'context' => 'type:test_content_type:name', - 'objectid' => 'test_content_type', - 'type' => 'type', - 'property' => 'name', - 'objectindex' => '0', - 'format' => '', -)) -->values(array( - 'lid' => '73', - 'textgroup' => 'node', - 'context' => 'type:test_content_type:title_label', - 'objectid' => 'test_content_type', - 'type' => 'type', - 'property' => 'title_label', - 'objectindex' => '0', - 'format' => '', -)) -->values(array( - 'lid' => '74', - 'textgroup' => 'node', - 'context' => 'type:test_content_type:description', - 'objectid' => 'test_content_type', - 'type' => 'type', - 'property' => 'description', - 'objectindex' => '0', - 'format' => '', -)) -->values(array( - 'lid' => '75', - 'textgroup' => 'node', - 'context' => 'type:test_content_type:help', - 'objectid' => 'test_content_type', - 'type' => 'type', - 'property' => 'help', - 'objectindex' => '0', - 'format' => '', -)) -->execute(); - $connection->schema()->createTable('accesslog', array( 'fields' => array( 'aid' => array( @@ -1316,6 +1036,12 @@ $connection->schema()->createTable('block', array( 'size' => 'normal', 'default' => '1', ), + 'i18n_mode' => array( + 'type' => 'int', + 'not null' => TRUE, + 'size' => 'normal', + 'default' => '0', + ), ), 'primary key' => array( 'bid', @@ -1337,6 +1063,7 @@ $connection->insert('block') 'pages', 'title', 'cache', + 'i18n_mode', )) ->values(array( 'bid' => '1', @@ -1351,6 +1078,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '-1', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '2', @@ -1365,6 +1093,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '-1', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '3', @@ -1379,6 +1108,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '-1', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '4', @@ -1391,8 +1121,9 @@ $connection->insert('block') 'custom' => '0', 'visibility' => '0', 'pages' => '', - 'title' => '', + 'title' => 'User login title', 'cache' => '-1', + 'i18n_mode' => '1', )) ->values(array( 'bid' => '5', @@ -1407,6 +1138,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '-1', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '6', @@ -1421,6 +1153,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '-1', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '7', @@ -1435,6 +1168,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '-1', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '8', @@ -1449,6 +1183,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '-1', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '9', @@ -1463,6 +1198,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '-1', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '10', @@ -1475,8 +1211,9 @@ $connection->insert('block') 'custom' => '0', 'visibility' => '0', 'pages' => '', - 'title' => '', + 'title' => 'User login title', 'cache' => '-1', + 'i18n_mode' => '1', )) ->values(array( 'bid' => '11', @@ -1491,6 +1228,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '-1', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '12', @@ -1505,6 +1243,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '-1', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '13', @@ -1519,6 +1258,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '1', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '14', @@ -1533,6 +1273,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '-1', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '15', @@ -1547,6 +1288,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '1', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '16', @@ -1561,6 +1303,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '-1', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '17', @@ -1575,6 +1318,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '-1', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '18', @@ -1589,6 +1333,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '-1', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '19', @@ -1603,6 +1348,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '-1', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '20', @@ -1617,6 +1363,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '1', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '21', @@ -1631,6 +1378,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '-1', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '22', @@ -1645,6 +1393,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '1', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '23', @@ -1659,6 +1408,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '-1', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '24', @@ -1673,6 +1423,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '-1', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '25', @@ -1687,6 +1438,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '-1', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '26', @@ -1701,6 +1453,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '-1', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '27', @@ -1715,6 +1468,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '-1', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '28', @@ -1729,6 +1483,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '-1', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '29', @@ -1743,6 +1498,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '-1', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '30', @@ -1757,6 +1513,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '-1', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '31', @@ -1771,6 +1528,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '1', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '32', @@ -1785,6 +1543,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '5', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '33', @@ -1799,6 +1558,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '-1', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '34', @@ -1813,6 +1573,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '-2', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '35', @@ -1827,6 +1588,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '-2', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '36', @@ -1841,6 +1603,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '1', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '37', @@ -1855,6 +1618,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '5', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '38', @@ -1869,6 +1633,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '-1', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '39', @@ -1883,6 +1648,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '-2', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '40', @@ -1897,6 +1663,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '-2', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '41', @@ -1911,6 +1678,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '-1', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '42', @@ -1925,6 +1693,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '-1', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '43', @@ -1939,6 +1708,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '-1', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '44', @@ -1953,6 +1723,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '-1', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '45', @@ -1967,6 +1738,7 @@ $connection->insert('block') 'pages' => '', 'title' => 'Mildly amusing limerick of the day', 'cache' => '-1', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '46', @@ -1981,6 +1753,7 @@ $connection->insert('block') 'pages' => '', 'title' => 'Mildly amusing limerick of the day', 'cache' => '-1', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '47', @@ -1995,6 +1768,7 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '1', + 'i18n_mode' => '0', )) ->values(array( 'bid' => '48', @@ -2009,6 +1783,37 @@ $connection->insert('block') 'pages' => '', 'title' => '', 'cache' => '1', + 'i18n_mode' => '0', +)) +->values(array( + 'bid' => '49', + 'module' => 'locale', + 'delta' => 'language_content', + 'theme' => 'bartik', + 'status' => '0', + 'weight' => '0', + 'region' => '-1', + 'custom' => '0', + 'visibility' => '0', + 'pages' => '', + 'title' => '', + 'cache' => '-1', + 'i18n_mode' => '0', +)) +->values(array( + 'bid' => '50', + 'module' => 'locale', + 'delta' => 'language_content', + 'theme' => 'seven', + 'status' => '0', + 'weight' => '0', + 'region' => '-1', + 'custom' => '0', + 'visibility' => '0', + 'pages' => '', + 'title' => '', + 'cache' => '-1', + 'i18n_mode' => '0', )) ->execute(); @@ -2632,6 +2437,49 @@ $connection->schema()->createTable('cache_update', array( 'mysql_character_set' => 'utf8', )); +$connection->schema()->createTable('cache_variable', array( + 'fields' => array( + 'cid' => array( + 'type' => 'varchar', + 'not null' => TRUE, + 'length' => '255', + 'default' => '', + ), + 'data' => array( + 'type' => 'blob', + 'not null' => FALSE, + 'size' => 'big', + ), + 'expire' => array( + 'type' => 'int', + 'not null' => TRUE, + 'size' => 'normal', + 'default' => '0', + ), + 'created' => array( + 'type' => 'int', + 'not null' => TRUE, + 'size' => 'normal', + 'default' => '0', + ), + 'serialized' => array( + 'type' => 'int', + 'not null' => TRUE, + 'size' => 'small', + 'default' => '0', + ), + ), + 'primary key' => array( + 'cid', + ), + 'indexes' => array( + 'expire' => array( + 'expire', + ), + ), + 'mysql_character_set' => 'utf8', +)); + $connection->schema()->createTable('cache_views', array( 'fields' => array( 'cid' => array( @@ -14702,6 +14550,296 @@ $connection->schema()->createTable('history', array( 'mysql_character_set' => 'utf8', )); +$connection->schema()->createTable('i18n_block_language', array( + 'fields' => array( + 'module' => array( + 'type' => 'varchar', + 'not null' => TRUE, + 'length' => '64', + ), + 'delta' => array( + 'type' => 'varchar', + 'not null' => TRUE, + 'length' => '32', + ), + 'language' => array( + 'type' => 'varchar', + 'not null' => TRUE, + 'length' => '12', + 'default' => '', + ), + ), + 'primary key' => array( + 'module', + 'delta', + 'language', + ), + 'indexes' => array( + 'language' => array( + 'language', + ), + ), + 'mysql_character_set' => 'utf8', +)); + +$connection->schema()->createTable('i18n_string', array( + 'fields' => array( + 'lid' => array( + 'type' => 'int', + 'not null' => TRUE, + 'size' => 'normal', + 'default' => '0', + ), + 'textgroup' => array( + 'type' => 'varchar', + 'not null' => TRUE, + 'length' => '50', + 'default' => 'default', + ), + 'context' => array( + 'type' => 'varchar', + 'not null' => TRUE, + 'length' => '255', + 'default' => '', + ), + 'objectid' => array( + 'type' => 'varchar', + 'not null' => TRUE, + 'length' => '255', + 'default' => '', + ), + 'type' => array( + 'type' => 'varchar', + 'not null' => TRUE, + 'length' => '255', + 'default' => '', + ), + 'property' => array( + 'type' => 'varchar', + 'not null' => TRUE, + 'length' => '255', + 'default' => '', + ), + 'objectindex' => array( + 'type' => 'int', + 'not null' => TRUE, + 'size' => 'big', + 'default' => '0', + ), + 'format' => array( + 'type' => 'varchar', + 'not null' => FALSE, + 'length' => '255', + ), + ), + 'primary key' => array( + 'lid', + ), + 'indexes' => array( + 'group_context' => array( + 'textgroup', + array( + 'context', + '50', + ), + ), + ), + 'mysql_character_set' => 'utf8', +)); + +$connection->insert('i18n_string') +->fields(array( + 'lid', + 'textgroup', + 'context', + 'objectid', + 'type', + 'property', + 'objectindex', + 'format', +)) +->values(array( + 'lid' => '57', + 'textgroup' => 'blocks', + 'context' => 'block:1:title', + 'objectid' => '1', + 'type' => 'block', + 'property' => 'title', + 'objectindex' => '1', + 'format' => '', +)) +->values(array( + 'lid' => '60', + 'textgroup' => 'blocks', + 'context' => 'block:1:body', + 'objectid' => '1', + 'type' => 'block', + 'property' => 'body', + 'objectindex' => '1', + 'format' => 'filtered_html', +)) +->values(array( + 'lid' => '61', + 'textgroup' => 'node', + 'context' => 'type:article:name', + 'objectid' => 'article', + 'type' => 'type', + 'property' => 'name', + 'objectindex' => '0', + 'format' => '', +)) +->values(array( + 'lid' => '62', + 'textgroup' => 'node', + 'context' => 'type:article:title_label', + 'objectid' => 'article', + 'type' => 'type', + 'property' => 'title_label', + 'objectindex' => '0', + 'format' => '', +)) +->values(array( + 'lid' => '63', + 'textgroup' => 'node', + 'context' => 'type:article:description', + 'objectid' => 'article', + 'type' => 'type', + 'property' => 'description', + 'objectindex' => '0', + 'format' => '', +)) +->values(array( + 'lid' => '64', + 'textgroup' => 'node', + 'context' => 'type:article:help', + 'objectid' => 'article', + 'type' => 'type', + 'property' => 'help', + 'objectindex' => '0', + 'format' => '', +)) +->values(array( + 'lid' => '65', + 'textgroup' => 'node', + 'context' => 'type:book:name', + 'objectid' => 'book', + 'type' => 'type', + 'property' => 'name', + 'objectindex' => '0', + 'format' => '', +)) +->values(array( + 'lid' => '66', + 'textgroup' => 'node', + 'context' => 'type:book:title_label', + 'objectid' => 'book', + 'type' => 'type', + 'property' => 'title_label', + 'objectindex' => '0', + 'format' => '', +)) +->values(array( + 'lid' => '67', + 'textgroup' => 'node', + 'context' => 'type:book:description', + 'objectid' => 'book', + 'type' => 'type', + 'property' => 'description', + 'objectindex' => '0', + 'format' => '', +)) +->values(array( + 'lid' => '68', + 'textgroup' => 'node', + 'context' => 'type:page:name', + 'objectid' => 'page', + 'type' => 'type', + 'property' => 'name', + 'objectindex' => '0', + 'format' => '', +)) +->values(array( + 'lid' => '69', + 'textgroup' => 'node', + 'context' => 'type:page:title_label', + 'objectid' => 'page', + 'type' => 'type', + 'property' => 'title_label', + 'objectindex' => '0', + 'format' => '', +)) +->values(array( + 'lid' => '70', + 'textgroup' => 'node', + 'context' => 'type:page:description', + 'objectid' => 'page', + 'type' => 'type', + 'property' => 'description', + 'objectindex' => '0', + 'format' => '', +)) +->values(array( + 'lid' => '71', + 'textgroup' => 'node', + 'context' => 'type:page:help', + 'objectid' => 'page', + 'type' => 'type', + 'property' => 'help', + 'objectindex' => '0', + 'format' => '', +)) +->values(array( + 'lid' => '72', + 'textgroup' => 'node', + 'context' => 'type:test_content_type:name', + 'objectid' => 'test_content_type', + 'type' => 'type', + 'property' => 'name', + 'objectindex' => '0', + 'format' => '', +)) +->values(array( + 'lid' => '73', + 'textgroup' => 'node', + 'context' => 'type:test_content_type:title_label', + 'objectid' => 'test_content_type', + 'type' => 'type', + 'property' => 'title_label', + 'objectindex' => '0', + 'format' => '', +)) +->values(array( + 'lid' => '74', + 'textgroup' => 'node', + 'context' => 'type:test_content_type:description', + 'objectid' => 'test_content_type', + 'type' => 'type', + 'property' => 'description', + 'objectindex' => '0', + 'format' => '', +)) +->values(array( + 'lid' => '75', + 'textgroup' => 'node', + 'context' => 'type:test_content_type:help', + 'objectid' => 'test_content_type', + 'type' => 'type', + 'property' => 'help', + 'objectindex' => '0', + 'format' => '', +)) +->values(array( + 'lid' => '76', + 'textgroup' => 'blocks', + 'context' => 'user:login:title', + 'objectid' => 'login', + 'type' => 'user', + 'property' => 'title', + 'objectindex' => '0', + 'format' => '', +)) +->execute(); + $connection->schema()->createTable('image_effects', array( 'fields' => array( 'ieid' => array( @@ -15010,6 +15148,15 @@ $connection->schema()->createTable('locales_source', array( 'primary key' => array( 'lid', ), + 'indexes' => array( + 'textgroup_context' => array( + 'textgroup', + array( + 'context', + '50', + ), + ), + ), 'mysql_character_set' => 'utf8', )); @@ -15622,6 +15769,14 @@ $connection->insert('locales_source') 'context' => 'type:test_content_type:help', 'version' => '1', )) +->values(array( + 'lid' => '76', + 'location' => 'blocks:user:login:title', + 'textgroup' => 'blocks', + 'source' => 'User login title', + 'context' => 'user:login:title', + 'version' => '1', +)) ->execute(); $connection->schema()->createTable('locales_target', array( @@ -15695,7 +15850,7 @@ $connection->insert('locales_target') 'plural' => '0', 'i18n_status' => '0', )) -->values(array( + ->values(array( 'lid' => '57', 'translation' => 'is - Mildly amusing limerick of the day', 'language' => 'is', @@ -15703,6 +15858,14 @@ $connection->insert('locales_target') 'plural' => '0', 'i18n_status' => '0', )) +->values(array( + 'lid' => '76', + 'translation' => 'fr - User login title', + 'language' => 'fr', + 'plid' => '0', + 'plural' => '0', + 'i18n_status' => '0', +)) ->execute(); $connection->schema()->createTable('menu_custom', array( @@ -46256,6 +46419,16 @@ $connection->insert('role_permission') 'permission' => 'switch shortcut sets', 'module' => 'shortcut', )) +->values(array( + 'rid' => '3', + 'permission' => 'translate admin strings', + 'module' => 'i18n_string', +)) +->values(array( + 'rid' => '3', + 'permission' => 'translate blocks', + 'module' => 'i18n_block', +)) ->values(array( 'rid' => '3', 'permission' => 'translate content', @@ -46266,6 +46439,11 @@ $connection->insert('role_permission') 'permission' => 'translate interface', 'module' => 'locale', )) +->values(array( + 'rid' => '3', + 'permission' => 'translate user-defined strings', + 'module' => 'i18n_string', +)) ->values(array( 'rid' => '3', 'permission' => 'use advanced search', @@ -48917,11 +49095,11 @@ $connection->insert('system') 'name' => 'i18n_block', 'type' => 'module', 'owner' => '', - 'status' => '0', + 'status' => '1', 'bootstrap' => '0', - 'schema_version' => '-1', - 'weight' => '0', - 'info' => 'a:12:{s:4:"name";s:15:"Block languages";s:11:"description";s:68:"Enables language selector for blocks and optional block translation.";s:12:"dependencies";a:2:{i:0;s:5:"block";i:1;s:11:"i18n_string";}s:7:"package";s:35:"Multilingual - Internationalization";s:4:"core";s:3:"7.x";s:5:"files";a:2:{i:0;s:14:"i18n_block.inc";i:1;s:15:"i18n_block.test";}s:7:"version";s:8:"7.x-1.26";s:7:"project";s:4:"i18n";s:9:"datestamp";s:10:"1534531985";s:5:"mtime";i:1534531985;s:3:"php";s:5:"5.2.4";s:9:"bootstrap";i:0;}', + 'schema_version' => '7001', + 'weight' => '100', + 'info' => 'a:12:{s:4:"name";s:15:"Block languages";s:11:"description";s:68:"Enables language selector for blocks and optional block translation.";s:12:"dependencies";a:2:{i:0;s:5:"block";i:1;s:11:"i18n_string";}s:7:"package";s:35:"Multilingual - Internationalization";s:4:"core";s:3:"7.x";s:5:"files";a:2:{i:0;s:14:"i18n_block.inc";i:1;s:15:"i18n_block.test";}s:7:"version";s:8:"7.x-1.25";s:7:"project";s:4:"i18n";s:9:"datestamp";s:10:"1531342125";s:5:"mtime";i:1537747250;s:3:"php";s:5:"5.2.4";s:9:"bootstrap";i:0;}', )) ->values(array( 'filename' => 'sites/all/modules/i18n/i18n_contact/i18n_contact.module', diff --git a/web/core/modules/migrate_drupal/tests/src/Kernel/Plugin/migrate/source/ContentEntityTest.php b/web/core/modules/migrate_drupal/tests/src/Kernel/Plugin/migrate/source/ContentEntityTest.php index 33e0197e2..cf5d3da4f 100644 --- a/web/core/modules/migrate_drupal/tests/src/Kernel/Plugin/migrate/source/ContentEntityTest.php +++ b/web/core/modules/migrate_drupal/tests/src/Kernel/Plugin/migrate/source/ContentEntityTest.php @@ -371,7 +371,7 @@ class ContentEntityTest extends KernelTestBase { $values = $media_source->current()->getSource(); $this->assertEquals(1, $values['mid']); $this->assertEquals('Foo media', $values['name'][0]['value']); - $this->assertEquals('Foo media', $values['thumbnail'][0]['title']); + $this->assertNull($values['thumbnail'][0]['title']); $this->assertEquals(1, $values['uid'][0]['target_id']); $this->assertEquals('image', $values['bundle'][0]['target_id']); } diff --git a/web/core/modules/migrate_drupal/tests/src/Unit/source/DrupalSqlBaseTest.php b/web/core/modules/migrate_drupal/tests/src/Unit/source/DrupalSqlBaseTest.php index ebd9334a2..fdaf4b10a 100644 --- a/web/core/modules/migrate_drupal/tests/src/Unit/source/DrupalSqlBaseTest.php +++ b/web/core/modules/migrate_drupal/tests/src/Unit/source/DrupalSqlBaseTest.php @@ -63,6 +63,22 @@ class DrupalSqlBaseTest extends MigrateTestCase { } } + /** + * @covers ::checkRequirements + */ + public function testSourceDatabaseError() { + $plugin_definition['requirements_met'] = TRUE; + $plugin_definition['source_module'] = 'module1'; + /** @var \Drupal\Core\State\StateInterface $state */ + $state = $this->getMock('Drupal\Core\State\StateInterface'); + /** @var \Drupal\Core\Entity\EntityManagerInterface $entity_manager */ + $entity_manager = $this->getMock('Drupal\Core\Entity\EntityManagerInterface'); + $plugin = new TestDrupalSqlBase([], 'test', $plugin_definition, $this->getMigration(), $state, $entity_manager); + $system_data = $plugin->getSystemData(); + $this->setExpectedException(RequirementsException::class, 'No database connection configured for source plugin test'); + $plugin->checkRequirements(); + } + } namespace Drupal\Tests\migrate_drupal\Unit\source; diff --git a/web/core/modules/migrate_drupal_ui/tests/src/Functional/d6/MigrateUpgrade6Test.php b/web/core/modules/migrate_drupal_ui/tests/src/Functional/d6/MigrateUpgrade6Test.php index 54ea5747a..b37b45b63 100644 --- a/web/core/modules/migrate_drupal_ui/tests/src/Functional/d6/MigrateUpgrade6Test.php +++ b/web/core/modules/migrate_drupal_ui/tests/src/Functional/d6/MigrateUpgrade6Test.php @@ -84,7 +84,7 @@ class MigrateUpgrade6Test extends MigrateUpgradeExecuteTestBase { 'shortcut_set' => 1, 'action' => 23, 'menu' => 8, - 'taxonomy_term' => 8, + 'taxonomy_term' => 15, 'taxonomy_vocabulary' => 7, 'tour' => 5, 'user' => 7, @@ -112,7 +112,7 @@ class MigrateUpgrade6Test extends MigrateUpgradeExecuteTestBase { $counts['file'] = 8; $counts['menu_link_content'] = 11; $counts['node'] = 19; - $counts['taxonomy_term'] = 9; + $counts['taxonomy_term'] = 16; $counts['user'] = 8; $counts['view'] = 16; return $counts; diff --git a/web/core/modules/migrate_drupal_ui/tests/src/Functional/d7/MigrateUpgrade7ReviewPageTest.php b/web/core/modules/migrate_drupal_ui/tests/src/Functional/d7/MigrateUpgrade7ReviewPageTest.php index 66b24c852..9e68d9dba 100644 --- a/web/core/modules/migrate_drupal_ui/tests/src/Functional/d7/MigrateUpgrade7ReviewPageTest.php +++ b/web/core/modules/migrate_drupal_ui/tests/src/Functional/d7/MigrateUpgrade7ReviewPageTest.php @@ -65,6 +65,7 @@ class MigrateUpgrade7ReviewPageTest extends MigrateUpgradeReviewPageTestBase { 'filter', 'forum', 'image', + 'i18n_block', 'language', 'link', 'list', @@ -141,7 +142,6 @@ class MigrateUpgrade7ReviewPageTest extends MigrateUpgradeReviewPageTestBase { 'entity_translation_i18n_menu', 'entity_translation_upgrade', 'i18n', - 'i18n_block', 'i18n_contact', 'i18n_field', 'i18n_forum', diff --git a/web/core/modules/node/node.post_update.php b/web/core/modules/node/node.post_update.php index 43e3cd6ac..913137757 100644 --- a/web/core/modules/node/node.post_update.php +++ b/web/core/modules/node/node.post_update.php @@ -27,3 +27,10 @@ function node_post_update_configure_status_field_widget() { ])->save(); } } + +/** + * Clear caches due to updated views data. + */ +function node_post_update_node_revision_views_data() { + // Empty post-update hook. +} diff --git a/web/core/modules/node/src/NodeViewsData.php b/web/core/modules/node/src/NodeViewsData.php index 2e095648c..35ef2978e 100644 --- a/web/core/modules/node/src/NodeViewsData.php +++ b/web/core/modules/node/src/NodeViewsData.php @@ -216,6 +216,10 @@ class NodeViewsData extends EntityViewsData { $data['node_field_revision']['nid']['relationship']['base field'] = 'nid'; $data['node_field_revision']['nid']['relationship']['title'] = $this->t('Content'); $data['node_field_revision']['nid']['relationship']['label'] = $this->t('Get the actual content from a content revision.'); + $data['node_field_revision']['nid']['relationship']['extra'][] = [ + 'field' => 'langcode', + 'left_field' => 'langcode', + ]; $data['node_field_revision']['vid'] = [ 'argument' => [ @@ -228,6 +232,12 @@ class NodeViewsData extends EntityViewsData { 'base field' => 'vid', 'title' => $this->t('Content'), 'label' => $this->t('Get the actual content from a content revision.'), + 'extra' => [ + [ + 'field' => 'langcode', + 'left_field' => 'langcode', + ], + ], ], ] + $data['node_field_revision']['vid']; diff --git a/web/core/modules/node/src/Plugin/migrate/source/d7/NodeType.php b/web/core/modules/node/src/Plugin/migrate/source/d7/NodeType.php index 44f0b5e06..e64b98908 100644 --- a/web/core/modules/node/src/Plugin/migrate/source/d7/NodeType.php +++ b/web/core/modules/node/src/Plugin/migrate/source/d7/NodeType.php @@ -40,7 +40,7 @@ class NodeType extends DrupalSqlBase { * {@inheritdoc} */ public function fields() { - return [ + $fields = [ 'type' => $this->t('Machine name of the node type.'), 'name' => $this->t('Human name of the node type.'), 'description' => $this->t('Description of the node type.'), diff --git a/web/core/modules/node/tests/modules/node_test_views/test_views/views.view.test_node_revision_nid.yml b/web/core/modules/node/tests/modules/node_test_views/test_views/views.view.test_node_revision_nid.yml index 179e1401c..8c5d4cb4b 100644 --- a/web/core/modules/node/tests/modules/node_test_views/test_views/views.view.test_node_revision_nid.yml +++ b/web/core/modules/node/tests/modules/node_test_views/test_views/views.view.test_node_revision_nid.yml @@ -44,6 +44,13 @@ display: plugin_id: field entity_type: node entity_field: nid + langcode: + id: langcode + table: node_field_revision + field: langcode + plugin_id: field + entity_type: node + entity_field: langcode arguments: nid: id: nid @@ -61,6 +68,21 @@ display: plugin_id: field entity_type: node entity_field: vid + langcode: + id: langcode + table: node_field_revision + field: langcode + relationship: none + group_type: group + admin_label: '' + order: DESC + exposed: false + expose: + label: '' + entity_type: node + entity_field: langcode + plugin_id: standard + display_extenders: { } display_plugin: default display_title: Master id: default diff --git a/web/core/modules/node/tests/modules/node_test_views/test_views/views.view.test_node_revision_vid.yml b/web/core/modules/node/tests/modules/node_test_views/test_views/views.view.test_node_revision_vid.yml index a2783a56d..3c9aac56f 100644 --- a/web/core/modules/node/tests/modules/node_test_views/test_views/views.view.test_node_revision_vid.yml +++ b/web/core/modules/node/tests/modules/node_test_views/test_views/views.view.test_node_revision_vid.yml @@ -44,6 +44,13 @@ display: plugin_id: field entity_type: node entity_field: nid + langcode: + id: langcode + table: node_field_revision + field: langcode + entity_type: node + entity_field: langcode + plugin_id: field arguments: nid: id: nid @@ -52,6 +59,22 @@ display: plugin_id: node_nid entity_type: node entity_field: nid + display_extenders: { } + sorts: + langcode: + id: langcode + table: node_field_revision + 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_plugin: default display_title: Master id: default diff --git a/web/core/modules/node/tests/src/Kernel/Views/RevisionRelationshipsTest.php b/web/core/modules/node/tests/src/Kernel/Views/RevisionRelationshipsTest.php index e47bdd038..b21610c5f 100644 --- a/web/core/modules/node/tests/src/Kernel/Views/RevisionRelationshipsTest.php +++ b/web/core/modules/node/tests/src/Kernel/Views/RevisionRelationshipsTest.php @@ -2,6 +2,7 @@ namespace Drupal\Tests\node\Kernel\Views; +use Drupal\language\Entity\ConfigurableLanguage; use Drupal\node\Entity\Node; use Drupal\node\Entity\NodeType; use Drupal\Tests\views\Kernel\ViewsKernelTestBase; @@ -20,7 +21,12 @@ class RevisionRelationshipsTest extends ViewsKernelTestBase { * * @var array */ - public static $modules = ['node' , 'node_test_views']; + public static $modules = [ + 'node', + 'node_test_views', + 'language', + 'content_translation', + ]; /** * {@inheritdoc} @@ -33,6 +39,8 @@ class RevisionRelationshipsTest extends ViewsKernelTestBase { $this->installEntitySchema('user'); $this->installEntitySchema('node'); + ConfigurableLanguage::createFromLangcode('fr')->save(); + ViewTestData::createTestViews(get_class($this), ['node_test_views']); } @@ -51,16 +59,22 @@ class RevisionRelationshipsTest extends ViewsKernelTestBase { $type->save(); $node = Node::create(['type' => 'page', 'title' => 'test', 'uid' => 1]); $node->save(); + + // Add a translation. + $translation = $node->addTranslation('fr', $node->toArray()); + $translation->save(); // Create revision of the node. $node->setNewRevision(TRUE); $node->save(); + $column_map = [ 'vid' => 'vid', 'node_field_data_node_field_revision_nid' => 'node_node_revision_nid', 'nid_1' => 'nid_1', + 'node_field_revision_langcode' => 'node_field_revision_langcode', ]; - // Here should be two rows. + // Here should be two rows for each translation. $view_nid = Views::getView('test_node_revision_nid'); $this->executeView($view_nid, [$node->id()]); $resultset_nid = [ @@ -68,17 +82,32 @@ class RevisionRelationshipsTest extends ViewsKernelTestBase { 'vid' => '1', 'node_node_revision_nid' => '1', 'nid_1' => '1', + 'node_field_revision_langcode' => 'fr', + ], + [ + 'vid' => '1', + 'node_node_revision_nid' => '1', + 'nid_1' => '1', + 'node_field_revision_langcode' => 'en', ], [ 'vid' => '2', 'node_revision_nid' => '1', 'node_node_revision_nid' => '1', 'nid_1' => '1', + 'node_field_revision_langcode' => 'fr', + ], + [ + 'vid' => '2', + 'node_revision_nid' => '1', + 'node_node_revision_nid' => '1', + 'nid_1' => '1', + 'node_field_revision_langcode' => 'en', ], ]; $this->assertIdenticalResultset($view_nid, $resultset_nid, $column_map); - // There should be only one row with active revision 2. + // There should be one row with active revision 2 for each translation. $view_vid = Views::getView('test_node_revision_vid'); $this->executeView($view_vid, [$node->id()]); $resultset_vid = [ @@ -86,6 +115,13 @@ class RevisionRelationshipsTest extends ViewsKernelTestBase { 'vid' => '2', 'node_node_revision_nid' => '1', 'nid_1' => '1', + 'node_field_revision_langcode' => 'en', + ], + [ + 'vid' => '2', + 'node_node_revision_nid' => '1', + 'nid_1' => '1', + 'node_field_revision_langcode' => 'fr', ], ]; $this->assertIdenticalResultset($view_vid, $resultset_vid, $column_map); diff --git a/web/core/modules/path/src/Plugin/Field/FieldType/PathFieldItemList.php b/web/core/modules/path/src/Plugin/Field/FieldType/PathFieldItemList.php index 41cc99441..1985499de 100644 --- a/web/core/modules/path/src/Plugin/Field/FieldType/PathFieldItemList.php +++ b/web/core/modules/path/src/Plugin/Field/FieldType/PathFieldItemList.php @@ -4,6 +4,7 @@ namespace Drupal\path\Plugin\Field\FieldType; use Drupal\Core\Access\AccessResult; use Drupal\Core\Field\FieldItemList; +use Drupal\Core\Language\LanguageInterface; use Drupal\Core\Session\AccountInterface; use Drupal\Core\TypedData\ComputedItemListTrait; @@ -26,12 +27,18 @@ class PathFieldItemList extends FieldItemList { $entity = $this->getEntity(); if (!$entity->isNew()) { - // @todo Support loading language neutral aliases in - // https://www.drupal.org/node/2511968. - $alias = \Drupal::service('path.alias_storage')->load([ + $conditions = [ 'source' => '/' . $entity->toUrl()->getInternalPath(), 'langcode' => $this->getLangcode(), - ]); + ]; + $alias = \Drupal::service('path.alias_storage')->load($conditions); + if ($alias === FALSE) { + // Fall back to non-specific language. + if ($this->getLangcode() !== LanguageInterface::LANGCODE_NOT_SPECIFIED) { + $conditions['langcode'] = LanguageInterface::LANGCODE_NOT_SPECIFIED; + $alias = \Drupal::service('path.alias_storage')->load($conditions); + } + } if ($alias) { $value = $alias; diff --git a/web/core/modules/path/src/Plugin/Field/FieldType/PathItem.php b/web/core/modules/path/src/Plugin/Field/FieldType/PathItem.php index cd62bea66..fc263ce60 100644 --- a/web/core/modules/path/src/Plugin/Field/FieldType/PathItem.php +++ b/web/core/modules/path/src/Plugin/Field/FieldType/PathItem.php @@ -63,10 +63,15 @@ class PathItem extends FieldItemBase { * {@inheritdoc} */ public function postSave($update) { + // If specified, rely on the langcode property for the language, so that the + // existing language of an alias can be kept. That could for example be + // unspecified even if the field/entity has a specific langcode. + $alias_langcode = ($this->langcode && $this->pid) ? $this->langcode : $this->getLangcode(); + if (!$update) { if ($this->alias) { $entity = $this->getEntity(); - if ($path = \Drupal::service('path.alias_storage')->save('/' . $entity->urlInfo()->getInternalPath(), $this->alias, $this->getLangcode())) { + if ($path = \Drupal::service('path.alias_storage')->save('/' . $entity->urlInfo()->getInternalPath(), $this->alias, $alias_langcode)) { $this->pid = $path['pid']; } } @@ -79,7 +84,7 @@ class PathItem extends FieldItemBase { // Only save a non-empty alias. elseif ($this->alias) { $entity = $this->getEntity(); - \Drupal::service('path.alias_storage')->save('/' . $entity->urlInfo()->getInternalPath(), $this->alias, $this->getLangcode(), $this->pid); + \Drupal::service('path.alias_storage')->save('/' . $entity->urlInfo()->getInternalPath(), $this->alias, $alias_langcode, $this->pid); } } } diff --git a/web/core/modules/path/src/Plugin/Validation/Constraint/PathAliasConstraintValidator.php b/web/core/modules/path/src/Plugin/Validation/Constraint/PathAliasConstraintValidator.php index e6771f76d..be6350f75 100644 --- a/web/core/modules/path/src/Plugin/Validation/Constraint/PathAliasConstraintValidator.php +++ b/web/core/modules/path/src/Plugin/Validation/Constraint/PathAliasConstraintValidator.php @@ -48,8 +48,14 @@ class PathAliasConstraintValidator extends ConstraintValidator implements Contai if ($entity && !$entity->isNew() && !$entity->isDefaultRevision()) { /** @var \Drupal\Core\Entity\ContentEntityInterface $original */ $original = $this->entityTypeManager->getStorage($entity->getEntityTypeId())->loadUnchanged($entity->id()); - if ($value->alias != $original->path->alias) { - $this->context->addViolation($constraint->message); + $entity_langcode = $entity->language()->getId(); + + // Only add the violation if the current translation does not have the + // same path alias. + if ($original->hasTranslation($entity_langcode)) { + if ($value->alias != $original->getTranslation($entity_langcode)->path->alias) { + $this->context->addViolation($constraint->message); + } } } } diff --git a/web/core/modules/path/tests/src/Functional/PathContentModerationTest.php b/web/core/modules/path/tests/src/Functional/PathContentModerationTest.php index 953f1116b..b69a5dbe5 100644 --- a/web/core/modules/path/tests/src/Functional/PathContentModerationTest.php +++ b/web/core/modules/path/tests/src/Functional/PathContentModerationTest.php @@ -2,7 +2,7 @@ namespace Drupal\Tests\path\Functional; -use Drupal\node\Entity\NodeType; +use Drupal\language\Entity\ConfigurableLanguage; use Drupal\Tests\BrowserTestBase; use Drupal\Tests\content_moderation\Traits\ContentModerationTestTrait; @@ -21,17 +21,26 @@ class PathContentModerationTest extends BrowserTestBase { * * @var array */ - public static $modules = ['node', 'path', 'content_moderation']; + public static $modules = [ + 'node', + 'path', + 'content_moderation', + 'content_translation', + ]; /** * {@inheritdoc} */ protected function setUp() { parent::setUp(); + ConfigurableLanguage::createFromLangcode('fr')->save(); + $this->rebuildContainer(); // Created a content type. - $node_type = NodeType::create(['name' => 'moderated', 'type' => 'moderated']); - $node_type->save(); + $this->drupalCreateContentType([ + 'name' => 'moderated', + 'type' => 'moderated', + ]); // Set the content type as moderated. $workflow = $this->createEditorialWorkflow(); @@ -39,6 +48,21 @@ class PathContentModerationTest extends BrowserTestBase { $workflow->save(); $this->drupalLogin($this->rootUser); + + // Enable URL language detection and selection. + $edit = ['language_interface[enabled][language-url]' => 1]; + $this->drupalPostForm('admin/config/regional/language/detection', $edit, 'Save settings'); + + // Enable translation for moderated node. + $edit = [ + 'entity_types[node]' => 1, + 'settings[node][moderated][translatable]' => 1, + 'settings[node][moderated][fields][path]' => 1, + 'settings[node][moderated][fields][body]' => 1, + 'settings[node][moderated][settings][language][language_alterable]' => 1, + ]; + $this->drupalPostForm('admin/config/regional/content-language', $edit, 'Save configuration'); + \Drupal::entityTypeManager()->clearCachedDefinitions(); } /** @@ -106,4 +130,104 @@ class PathContentModerationTest extends BrowserTestBase { $this->assertSession()->pageTextNotContains('You can only change the URL alias for the published version of this content.'); } + /** + * Tests that translated and moderated node can get new draft revision. + */ + public function testTranslatedModeratedNodeAlias() { + // Create one node with a random alias. + $default_node = $this->drupalCreateNode([ + 'type' => 'moderated', + 'langcode' => 'en', + 'moderation_state' => 'published', + 'path' => '/' . $this->randomMachineName(), + ]); + + // Add published translation with another alias. + $this->drupalGet('node/' . $default_node->id()); + $this->drupalGet('node/' . $default_node->id() . '/translations'); + $this->clickLink('Add'); + $edit_translation = [ + 'body[0][value]' => $this->randomMachineName(), + 'moderation_state[0][state]' => 'published', + 'path[0][alias]' => '/' . $this->randomMachineName(), + ]; + $this->drupalPostForm(NULL, $edit_translation, 'Save (this translation)'); + // Confirm that the alias works. + $this->drupalGet('fr' . $edit_translation['path[0][alias]']); + $this->assertSession()->pageTextContains($edit_translation['body[0][value]']); + + $default_path = $default_node->path->alias; + $translation_path = 'fr' . $edit_translation['path[0][alias]']; + + $this->assertPathsAreAccessible([$default_path, $translation_path]); + + // Try to create new draft revision for translation with a new path alias. + $edit_new_translation_draft_with_alias = [ + 'moderation_state[0][state]' => 'draft', + 'path[0][alias]' => '/' . $this->randomMachineName(), + ]; + $this->drupalPostForm('fr/node/' . $default_node->id() . '/edit', $edit_new_translation_draft_with_alias, 'Save (this translation)'); + // Confirm the expected error. + $this->assertSession()->pageTextContains('You can only change the URL alias for the published version of this content.'); + + // Create new draft revision for translation without changing path alias. + $edit_new_translation_draft = [ + 'body[0][value]' => $this->randomMachineName(), + 'moderation_state[0][state]' => 'draft', + ]; + $this->drupalPostForm('fr/node/' . $default_node->id() . '/edit', $edit_new_translation_draft, t('Save (this translation)')); + // Confirm that the new draft revision was created. + $this->assertSession()->pageTextNotContains('You can only change the URL alias for the published version of this content.'); + $this->assertSession()->pageTextContains($edit_new_translation_draft['body[0][value]']); + $this->assertPathsAreAccessible([$default_path, $translation_path]); + + // Try to create a new draft revision for translation with path alias from + // the original language's default revision. + $edit_new_translation_draft_with_defaults_alias = [ + 'moderation_state[0][state]' => 'draft', + 'path[0][alias]' => $default_node->path->alias, + ]; + $this->drupalPostForm('fr/node/' . $default_node->id() . '/edit', $edit_new_translation_draft_with_defaults_alias, 'Save (this translation)'); + // Verify the expected error. + $this->assertSession()->pageTextContains('You can only change the URL alias for the published version of this content.'); + + // Try to create new draft revision for translation with deleted (empty) + // path alias. + $edit_new_translation_draft_empty_alias = [ + 'body[0][value]' => $this->randomMachineName(), + 'moderation_state[0][state]' => 'draft', + 'path[0][alias]' => '', + ]; + $this->drupalPostForm('fr/node/' . $default_node->id() . '/edit', $edit_new_translation_draft_empty_alias, 'Save (this translation)'); + // Confirm the expected error. + $this->assertSession()->pageTextContains('You can only change the URL alias for the published version of this content.'); + + // Create new default (published) revision for translation with new path + // alias. + $edit_new_translation = [ + 'body[0][value]' => $this->randomMachineName(), + 'moderation_state[0][state]' => 'published', + 'path[0][alias]' => '/' . $this->randomMachineName(), + ]; + $this->drupalPostForm('fr/node/' . $default_node->id() . '/edit', $edit_new_translation, 'Save (this translation)'); + // Confirm that the new published revision was created. + $this->assertSession()->pageTextNotContains('You can only change the URL alias for the published version of this content.'); + $this->assertSession()->pageTextContains($edit_new_translation['body[0][value]']); + $this->assertSession()->addressEquals('fr' . $edit_new_translation['path[0][alias]']); + $this->assertPathsAreAccessible([$default_path]); + } + + /** + * Helper callback to verify paths are responding with status 200. + * + * @param string[] $paths + * An array of paths to check for. + */ + public function assertPathsAreAccessible(array $paths) { + foreach ($paths as $path) { + $this->drupalGet($path); + $this->assertSession()->statusCodeEquals(200); + } + } + } diff --git a/web/core/modules/path/tests/src/Functional/PathLanguageUiTest.php b/web/core/modules/path/tests/src/Functional/PathLanguageUiTest.php index c2e327624..c76284515 100644 --- a/web/core/modules/path/tests/src/Functional/PathLanguageUiTest.php +++ b/web/core/modules/path/tests/src/Functional/PathLanguageUiTest.php @@ -2,6 +2,8 @@ namespace Drupal\Tests\path\Functional; +use Drupal\Core\Language\LanguageInterface; + /** * Confirm that the Path module user interface works with languages. * @@ -78,4 +80,36 @@ class PathLanguageUiTest extends PathTestBase { $this->assertText(t('Filter aliases'), 'Foreign URL alias works'); } + /** + * Test that language unspecific aliases are shown and saved in the node form. + */ + public function testNotSpecifiedNode() { + // Create test node. + $node = $this->drupalCreateNode(); + + // Create a language-unspecific alias in the admin UI, ensure that is + // displayed and the langcode is not changed when saving. + $edit = [ + 'source' => '/node/' . $node->id(), + 'alias' => '/' . $this->getRandomGenerator()->word(8), + 'langcode' => LanguageInterface::LANGCODE_NOT_SPECIFIED, + ]; + $this->drupalPostForm('admin/config/search/path/add', $edit, t('Save')); + + $this->drupalGet($node->toUrl('edit-form')); + $this->assertSession()->fieldValueEquals('path[0][alias]', $edit['alias']); + $this->drupalPostForm(NULL, [], t('Save')); + + $this->drupalGet('admin/config/search/path'); + $this->assertSession()->pageTextContains('None'); + $this->assertSession()->pageTextNotContains('English'); + + // Create another node, with no alias, to ensure non-language specific + // aliases are loaded correctly. + $node = $this->drupalCreateNode(); + $this->drupalget($node->toUrl('edit-form')); + $this->drupalPostForm(NULL, [], t('Save')); + $this->assertSession()->pageTextNotContains(t('The alias is already in use.')); + } + } diff --git a/web/core/modules/shortcut/src/ShortcutForm.php b/web/core/modules/shortcut/src/ShortcutForm.php index 26a52629b..f41ba2531 100644 --- a/web/core/modules/shortcut/src/ShortcutForm.php +++ b/web/core/modules/shortcut/src/ShortcutForm.php @@ -19,6 +19,16 @@ class ShortcutForm extends ContentEntityForm { */ protected $entity; + /** + * {@inheritdoc} + */ + public function form(array $form, FormStateInterface $form_state) { + $form = parent::form($form, $form_state); + $form['#attached']['library'][] = 'core/drupal.form'; + + return $form; + } + /** * {@inheritdoc} */ diff --git a/web/core/modules/simpletest/simpletest.module b/web/core/modules/simpletest/simpletest.module index 0814d882d..35fdd92cc 100644 --- a/web/core/modules/simpletest/simpletest.module +++ b/web/core/modules/simpletest/simpletest.module @@ -855,6 +855,7 @@ function simpletest_phpunit_testcase_to_row($test_id, \SimpleXMLElement $testcas $attributes = $testcase->attributes(); + $function = $attributes->class . '->' . $attributes->name . '()'; $record = [ 'test_id' => $test_id, 'test_class' => (string) $attributes->class, @@ -862,9 +863,11 @@ function simpletest_phpunit_testcase_to_row($test_id, \SimpleXMLElement $testcas 'message' => $message, // @todo: Check on the proper values for this. 'message_group' => 'Other', - 'function' => $attributes->class . '->' . $attributes->name . '()', + 'function' => $function, 'line' => $attributes->line ?: 0, - 'file' => $attributes->file, + // There are situations when the file will not be present because a PHPUnit + // @requires has caused a test to be skipped. + 'file' => $attributes->file ?: $function, ]; return $record; } diff --git a/web/core/modules/system/src/Tests/Ajax/ElementValidationTest.php b/web/core/modules/system/src/Tests/Ajax/ElementValidationTest.php deleted file mode 100644 index 169a38900..000000000 --- a/web/core/modules/system/src/Tests/Ajax/ElementValidationTest.php +++ /dev/null @@ -1,39 +0,0 @@ - t('some dumb text')]; - - // Post with 'drivertext' as the triggering element. - $this->drupalPostAjaxForm('ajax_validation_test', $edit, 'drivertext'); - // Look for a validation failure in the resultant JSON. - $this->assertNoText(t('Error message'), 'No error message in resultant JSON'); - $this->assertText('ajax_forms_test_validation_form_callback invoked', 'The correct callback was invoked'); - - $this->drupalGet('ajax_validation_test'); - $edit = ['drivernumber' => 12345]; - - // Post with 'drivernumber' as the triggering element. - $this->drupalPostAjaxForm('ajax_validation_test', $edit, 'drivernumber'); - // Look for a validation failure in the resultant JSON. - $this->assertNoText(t('Error message'), 'No error message in resultant JSON'); - $this->assertText('ajax_forms_test_validation_number_form_callback invoked', 'The correct callback was invoked'); - } - -} diff --git a/web/core/modules/system/src/Tests/Ajax/FormValuesTest.php b/web/core/modules/system/src/Tests/Ajax/FormValuesTest.php deleted file mode 100644 index 358db8933..000000000 --- a/web/core/modules/system/src/Tests/Ajax/FormValuesTest.php +++ /dev/null @@ -1,65 +0,0 @@ -drupalLogin($this->drupalCreateUser(['access content'])); - } - - /** - * Submits forms with select and checkbox elements via Ajax. - */ - public function testSimpleAjaxFormValue() { - // Verify form values of a select element. - foreach (['red', 'green', 'blue'] as $item) { - $edit = [ - 'select' => $item, - ]; - $commands = $this->drupalPostAjaxForm('ajax_forms_test_get_form', $edit, 'select'); - $expected = new DataCommand('#ajax_selected_color', 'form_state_value_select', $item); - $this->assertCommand($commands, $expected->render(), 'Verification of AJAX form values from a selectbox issued with a correct value.'); - } - - // Verify form values of a checkbox element. - foreach ([FALSE, TRUE] as $item) { - $edit = [ - 'checkbox' => $item, - ]; - $commands = $this->drupalPostAjaxForm('ajax_forms_test_get_form', $edit, 'checkbox'); - $expected = new DataCommand('#ajax_checkbox_value', 'form_state_value_select', (int) $item); - $this->assertCommand($commands, $expected->render(), 'Verification of AJAX form values from a checkbox issued with a correct value.'); - } - - // Verify that AJAX elements with invalid callbacks return error code 500. - // Ensure the test error log is empty before these tests. - $this->assertNoErrorsLogged(); - // We don't need to check for the X-Drupal-Ajax-Token header with these - // invalid requests. - $this->assertAjaxHeader = FALSE; - foreach (['null', 'empty', 'nonexistent'] as $key) { - $element_name = 'select_' . $key . '_callback'; - $edit = [ - $element_name => 'red', - ]; - $commands = $this->drupalPostAjaxForm('ajax_forms_test_get_form', $edit, $element_name); - $this->assertResponse(500); - } - // Switch this back to the default. - $this->assertAjaxHeader = TRUE; - // The exceptions are expected. Do not interpret them as a test failure. - // Not using File API; a potential error must trigger a PHP warning. - unlink(\Drupal::root() . '/' . $this->siteDirectory . '/error.log'); - } - -} diff --git a/web/core/modules/system/src/Tests/Ajax/FrameworkTest.php b/web/core/modules/system/src/Tests/Ajax/FrameworkTest.php deleted file mode 100644 index 507493b7d..000000000 --- a/web/core/modules/system/src/Tests/Ajax/FrameworkTest.php +++ /dev/null @@ -1,216 +0,0 @@ -drupalGetAjax('ajax-test/render'); - $expected = new SettingsCommand(['ajax' => 'test'], TRUE); - $this->assertCommand($commands, $expected->render(), 'JavaScript settings command is present.'); - } - - /** - * Tests AjaxResponse::prepare() AJAX commands ordering. - */ - public function testOrder() { - $expected_commands = []; - - // Expected commands, in a very specific order. - $asset_resolver = \Drupal::service('asset.resolver'); - $css_collection_renderer = \Drupal::service('asset.css.collection_renderer'); - $js_collection_renderer = \Drupal::service('asset.js.collection_renderer'); - $renderer = \Drupal::service('renderer'); - $expected_commands[0] = new SettingsCommand(['ajax' => 'test'], TRUE); - $build['#attached']['library'][] = 'ajax_test/order-css-command'; - $assets = AttachedAssets::createFromRenderArray($build); - $css_render_array = $css_collection_renderer->render($asset_resolver->getCssAssets($assets, FALSE)); - $expected_commands[1] = new AddCssCommand($renderer->renderRoot($css_render_array)); - $build['#attached']['library'][] = 'ajax_test/order-header-js-command'; - $build['#attached']['library'][] = 'ajax_test/order-footer-js-command'; - $assets = AttachedAssets::createFromRenderArray($build); - list($js_assets_header, $js_assets_footer) = $asset_resolver->getJsAssets($assets, FALSE); - $js_header_render_array = $js_collection_renderer->render($js_assets_header); - $js_footer_render_array = $js_collection_renderer->render($js_assets_footer); - $expected_commands[2] = new PrependCommand('head', $js_header_render_array); - $expected_commands[3] = new AppendCommand('body', $js_footer_render_array); - $expected_commands[4] = new HtmlCommand('body', 'Hello, world!'); - - // Load any page with at least one CSS file, at least one JavaScript file - // and at least one #ajax-powered element. The latter is an assumption of - // drupalPostAjaxForm(), the two former are assumptions of the Ajax - // renderer. - // @todo refactor AJAX Framework + tests to make less assumptions. - $this->drupalGet('ajax_forms_test_lazy_load_form'); - - // Verify AJAX command order — this should always be the order: - // 1. JavaScript settings - // 2. CSS files - // 3. JavaScript files in the header - // 4. JavaScript files in the footer - // 5. Any other AJAX commands, in whatever order they were added. - $commands = $this->drupalPostAjaxForm(NULL, [], NULL, 'ajax-test/order', [], [], NULL, []); - $this->assertCommand(array_slice($commands, 0, 1), $expected_commands[0]->render(), 'Settings command is first.'); - $this->assertCommand(array_slice($commands, 1, 1), $expected_commands[1]->render(), 'CSS command is second (and CSS files are ordered correctly).'); - $this->assertCommand(array_slice($commands, 2, 1), $expected_commands[2]->render(), 'Header JS command is third.'); - $this->assertCommand(array_slice($commands, 3, 1), $expected_commands[3]->render(), 'Footer JS command is fourth.'); - $this->assertCommand(array_slice($commands, 4, 1), $expected_commands[4]->render(), 'HTML command is fifth.'); - } - - /** - * Tests the behavior of an error alert command. - */ - public function testAJAXRenderError() { - // Verify custom error message. - $edit = [ - 'message' => 'Custom error message.', - ]; - $commands = $this->drupalGetAjax('ajax-test/render-error', ['query' => $edit]); - $expected = new AlertCommand($edit['message']); - $this->assertCommand($commands, $expected->render(), 'Custom error message is output.'); - } - - /** - * Tests that new JavaScript and CSS files are lazy-loaded on an AJAX request. - */ - public function testLazyLoad() { - $asset_resolver = \Drupal::service('asset.resolver'); - $css_collection_renderer = \Drupal::service('asset.css.collection_renderer'); - $js_collection_renderer = \Drupal::service('asset.js.collection_renderer'); - $renderer = \Drupal::service('renderer'); - - $expected = [ - 'setting_name' => 'ajax_forms_test_lazy_load_form_submit', - 'setting_value' => 'executed', - 'library_1' => 'system/admin', - 'library_2' => 'system/drupal.system', - ]; - - // Get the base page. - $this->drupalGet('ajax_forms_test_lazy_load_form'); - $original_settings = $this->getDrupalSettings(); - $original_libraries = explode(',', $original_settings['ajaxPageState']['libraries']); - - // Verify that the base page doesn't have the settings and files that are to - // be lazy loaded as part of the next requests. - $this->assertTrue(!isset($original_settings[$expected['setting_name']]), format_string('Page originally lacks the %setting, as expected.', ['%setting' => $expected['setting_name']])); - $this->assertTrue(!in_array($expected['library_1'], $original_libraries), format_string('Page originally lacks the %library library, as expected.', ['%library' => $expected['library_1']])); - $this->assertTrue(!in_array($expected['library_2'], $original_libraries), format_string('Page originally lacks the %library library, as expected.', ['%library' => $expected['library_2']])); - - // Calculate the expected CSS and JS. - $assets = new AttachedAssets(); - $assets->setLibraries([$expected['library_1']]) - ->setAlreadyLoadedLibraries($original_libraries); - $css_render_array = $css_collection_renderer->render($asset_resolver->getCssAssets($assets, FALSE)); - $expected_css_html = $renderer->renderRoot($css_render_array); - - $assets->setLibraries([$expected['library_2']]) - ->setAlreadyLoadedLibraries($original_libraries); - $js_assets = $asset_resolver->getJsAssets($assets, FALSE)[1]; - unset($js_assets['drupalSettings']); - $js_render_array = $js_collection_renderer->render($js_assets); - $expected_js_html = $renderer->renderRoot($js_render_array); - - // Submit the AJAX request without triggering files getting added. - $commands = $this->drupalPostAjaxForm(NULL, ['add_files' => FALSE], ['op' => t('Submit')]); - $new_settings = $this->getDrupalSettings(); - $new_libraries = explode(',', $new_settings['ajaxPageState']['libraries']); - - // Verify the setting was not added when not expected. - $this->assertTrue(!isset($new_settings[$expected['setting_name']]), format_string('Page still lacks the %setting, as expected.', ['%setting' => $expected['setting_name']])); - $this->assertTrue(!in_array($expected['library_1'], $new_libraries), format_string('Page still lacks the %library library, as expected.', ['%library' => $expected['library_1']])); - $this->assertTrue(!in_array($expected['library_2'], $new_libraries), format_string('Page still lacks the %library library, as expected.', ['%library' => $expected['library_2']])); - // Verify a settings command does not add CSS or scripts to drupalSettings - // and no command inserts the corresponding tags on the page. - $found_settings_command = FALSE; - $found_markup_command = FALSE; - foreach ($commands as $command) { - if ($command['command'] == 'settings' && (array_key_exists('css', $command['settings']['ajaxPageState']) || array_key_exists('js', $command['settings']['ajaxPageState']))) { - $found_settings_command = TRUE; - } - if (isset($command['data']) && ($command['data'] == $expected_js_html || $command['data'] == $expected_css_html)) { - $found_markup_command = TRUE; - } - } - $this->assertFalse($found_settings_command, format_string('Page state still lacks the %library_1 and %library_2 libraries, as expected.', ['%library_1' => $expected['library_1'], '%library_2' => $expected['library_2']])); - $this->assertFalse($found_markup_command, format_string('Page still lacks the %library_1 and %library_2 libraries, as expected.', ['%library_1' => $expected['library_1'], '%library_2' => $expected['library_2']])); - - // Submit the AJAX request and trigger adding files. - $commands = $this->drupalPostAjaxForm(NULL, ['add_files' => TRUE], ['op' => t('Submit')]); - $new_settings = $this->getDrupalSettings(); - $new_libraries = explode(',', $new_settings['ajaxPageState']['libraries']); - - // Verify the expected setting was added, both to drupalSettings, and as - // the first AJAX command. - $this->assertIdentical($new_settings[$expected['setting_name']], $expected['setting_value'], format_string('Page now has the %setting.', ['%setting' => $expected['setting_name']])); - $expected_command = new SettingsCommand([$expected['setting_name'] => $expected['setting_value']], TRUE); - $this->assertCommand(array_slice($commands, 0, 1), $expected_command->render(), 'The settings command was first.'); - - // Verify the expected CSS file was added, both to drupalSettings, and as - // the second AJAX command for inclusion into the HTML. - $this->assertTrue(in_array($expected['library_1'], $new_libraries), format_string('Page state now has the %library library.', ['%library' => $expected['library_1']])); - $this->assertCommand(array_slice($commands, 1, 1), ['data' => $expected_css_html], format_string('Page now has the %library library.', ['%library' => $expected['library_1']])); - - // Verify the expected JS file was added, both to drupalSettings, and as - // the third AJAX command for inclusion into the HTML. By testing for an - // exact HTML string containing the SCRIPT tag, we also ensure that - // unexpected JavaScript code, such as a jQuery.extend() that would - // potentially clobber rather than properly merge settings, didn't - // accidentally get added. - $this->assertTrue(in_array($expected['library_2'], $new_libraries), format_string('Page state now has the %library library.', ['%library' => $expected['library_2']])); - $this->assertCommand(array_slice($commands, 2, 1), ['data' => $expected_js_html], format_string('Page now has the %library library.', ['%library' => $expected['library_2']])); - } - - /** - * Tests that drupalSettings.currentPath is not updated on AJAX requests. - */ - public function testCurrentPathChange() { - $commands = $this->drupalPostAjaxForm('ajax_forms_test_lazy_load_form', ['add_files' => FALSE], ['op' => t('Submit')]); - foreach ($commands as $command) { - if ($command['command'] == 'settings') { - $this->assertFalse(isset($command['settings']['currentPath']), 'Value of drupalSettings.currentPath is not updated after an AJAX request.'); - } - } - } - - /** - * Tests that overridden CSS files are not added during lazy load. - */ - public function testLazyLoadOverriddenCSS() { - // The test theme overrides js.module.css without an implementation, - // thereby removing it. - \Drupal::service('theme_handler')->install(['test_theme']); - $this->config('system.theme') - ->set('default', 'test_theme') - ->save(); - - // This gets the form, and emulates an Ajax submission on it, including - // adding markup to the HEAD and BODY for any lazy loaded JS/CSS files. - $this->drupalPostAjaxForm('ajax_forms_test_lazy_load_form', ['add_files' => TRUE], ['op' => t('Submit')]); - - // Verify that the resulting HTML does not load the overridden CSS file. - // We add a "?" to the assertion, because drupalSettings may include - // information about the file; we only really care about whether it appears - // in a LINK or STYLE tag, for which Drupal always adds a query string for - // cache control. - $this->assertNoText('js.module.css?', 'Ajax lazy loading does not add overridden CSS files.'); - } - -} diff --git a/web/core/modules/system/src/Tests/Form/TriggeringElementTest.php b/web/core/modules/system/src/Tests/Form/TriggeringElementTest.php deleted file mode 100644 index fb59cb5e8..000000000 --- a/web/core/modules/system/src/Tests/Form/TriggeringElementTest.php +++ /dev/null @@ -1,97 +0,0 @@ -drupalPostForm($path, $edit, NULL, [], [], $form_html_id); - $this->assertText('There is no clicked button.', '$form_state->getTriggeringElement() set to NULL.'); - $this->assertNoText('Submit handler for form_test_clicked_button executed.', 'Form submit handler did not execute.'); - - // Ensure submitting a form with one or more submit buttons results in the - // triggering element being set to the first one the user has access to. An - // argument with 'r' in it indicates a restricted (#access=FALSE) button. - $this->drupalPostForm($path . '/s', $edit, NULL, [], [], $form_html_id); - $this->assertText('The clicked button is button1.', '$form_state->getTriggeringElement() set to only button.'); - $this->assertText('Submit handler for form_test_clicked_button executed.', 'Form submit handler executed.'); - - $this->drupalPostForm($path . '/s/s', $edit, NULL, [], [], $form_html_id); - $this->assertText('The clicked button is button1.', '$form_state->getTriggeringElement() set to first button.'); - $this->assertText('Submit handler for form_test_clicked_button executed.', 'Form submit handler executed.'); - - $this->drupalPostForm($path . '/rs/s', $edit, NULL, [], [], $form_html_id); - $this->assertText('The clicked button is button2.', '$form_state->getTriggeringElement() set to first available button.'); - $this->assertText('Submit handler for form_test_clicked_button executed.', 'Form submit handler executed.'); - - // Ensure submitting a form with buttons of different types results in the - // triggering element being set to the first button, regardless of type. For - // the FAPI 'button' type, this should result in the submit handler not - // executing. The types are 's'(ubmit), 'b'(utton), and 'i'(mage_button). - $this->drupalPostForm($path . '/s/b/i', $edit, NULL, [], [], $form_html_id); - $this->assertText('The clicked button is button1.', '$form_state->getTriggeringElement() set to first button.'); - $this->assertText('Submit handler for form_test_clicked_button executed.', 'Form submit handler executed.'); - - $this->drupalPostForm($path . '/b/s/i', $edit, NULL, [], [], $form_html_id); - $this->assertText('The clicked button is button1.', '$form_state->getTriggeringElement() set to first button.'); - $this->assertNoText('Submit handler for form_test_clicked_button executed.', 'Form submit handler did not execute.'); - - $this->drupalPostForm($path . '/i/s/b', $edit, NULL, [], [], $form_html_id); - $this->assertText('The clicked button is button1.', '$form_state->getTriggeringElement() set to first button.'); - $this->assertText('Submit handler for form_test_clicked_button executed.', 'Form submit handler executed.'); - } - - /** - * Test that the triggering element does not get set to a button with - * #access=FALSE. - */ - public function testAttemptAccessControlBypass() { - $path = 'form-test/clicked-button'; - $form_html_id = 'form-test-clicked-button'; - - // Retrieve a form where 'button1' has #access=FALSE and 'button2' doesn't. - $this->drupalGet($path . '/rs/s'); - - // Submit the form with 'button1=button1' in the POST data, which someone - // trying to get around security safeguards could easily do. We have to do - // a little trickery here, to work around the safeguards in drupalPostForm(): by - // renaming the text field that is in the form to 'button1', we can get the - // data we want into \Drupal::request()->request. - $elements = $this->xpath('//form[@id="' . $form_html_id . '"]//input[@name="text"]'); - $elements[0]['name'] = 'button1'; - $this->drupalPostForm(NULL, ['button1' => 'button1'], NULL, [], [], $form_html_id); - - // Ensure that the triggering element was not set to the restricted button. - // Do this with both a negative and positive assertion, because negative - // assertions alone can be brittle. See testNoButtonInfoInPost() for why the - // triggering element gets set to 'button2'. - $this->assertNoText('The clicked button is button1.', '$form_state->getTriggeringElement() not set to a restricted button.'); - $this->assertText('The clicked button is button2.', '$form_state->getTriggeringElement() not set to a restricted button.'); - } - -} diff --git a/web/core/modules/system/src/Tests/Session/StackSessionHandlerIntegrationTest.php b/web/core/modules/system/src/Tests/Session/StackSessionHandlerIntegrationTest.php deleted file mode 100644 index ac368685d..000000000 --- a/web/core/modules/system/src/Tests/Session/StackSessionHandlerIntegrationTest.php +++ /dev/null @@ -1,47 +0,0 @@ -drupalGetAjax('session-test/trace-handler'); - $expect_trace = [ - ['BEGIN', 'test_argument', 'open'], - ['BEGIN', NULL, 'open'], - ['END', NULL, 'open'], - ['END', 'test_argument', 'open'], - ['BEGIN', 'test_argument', 'read', $this->sessionId], - ['BEGIN', NULL, 'read', $this->sessionId], - ['END', NULL, 'read', $this->sessionId], - ['END', 'test_argument', 'read', $this->sessionId], - ['BEGIN', 'test_argument', 'write', $this->sessionId], - ['BEGIN', NULL, 'write', $this->sessionId], - ['END', NULL, 'write', $this->sessionId], - ['END', 'test_argument', 'write', $this->sessionId], - ['BEGIN', 'test_argument', 'close'], - ['BEGIN', NULL, 'close'], - ['END', NULL, 'close'], - ['END', 'test_argument', 'close'], - ]; - $this->assertEqual($expect_trace, $actual_trace); - } - -} diff --git a/web/core/modules/system/tests/modules/ajax_forms_test/src/Callbacks.php b/web/core/modules/system/tests/modules/ajax_forms_test/src/Callbacks.php index 71350305f..cf821515b 100644 --- a/web/core/modules/system/tests/modules/ajax_forms_test/src/Callbacks.php +++ b/web/core/modules/system/tests/modules/ajax_forms_test/src/Callbacks.php @@ -50,7 +50,7 @@ class Callbacks { */ public function checkboxCallback($form, FormStateInterface $form_state) { $response = new AjaxResponse(); - $response->addCommand(new HtmlCommand('#ajax_checkbox_value', (int) $form_state->getValue('checkbox'))); + $response->addCommand(new HtmlCommand('#ajax_checkbox_value', $form_state->getValue('checkbox') ? 'checked' : 'unchecked')); $response->addCommand(new DataCommand('#ajax_checkbox_value', 'form_state_value_select', (int) $form_state->getValue('checkbox'))); return $response; } diff --git a/web/core/modules/system/tests/modules/ajax_forms_test/src/Form/AjaxFormsTestSimpleForm.php b/web/core/modules/system/tests/modules/ajax_forms_test/src/Form/AjaxFormsTestSimpleForm.php index 3cb810531..35e0b08e2 100644 --- a/web/core/modules/system/tests/modules/ajax_forms_test/src/Form/AjaxFormsTestSimpleForm.php +++ b/web/core/modules/system/tests/modules/ajax_forms_test/src/Form/AjaxFormsTestSimpleForm.php @@ -65,7 +65,7 @@ class AjaxFormsTestSimpleForm extends FormBase { $form['select_' . $key . '_callback'] = [ '#type' => 'select', '#title' => $this->t('Test %key callbacks', ['%key' => $key]), - '#options' => ['red' => 'red'], + '#options' => ['red' => 'red', 'green' => 'green'], '#ajax' => ['callback' => $value], ]; } diff --git a/web/core/modules/system/tests/src/Functional/Ajax/FrameworkTest.php b/web/core/modules/system/tests/src/Functional/Ajax/FrameworkTest.php new file mode 100644 index 000000000..cc7b782d5 --- /dev/null +++ b/web/core/modules/system/tests/src/Functional/Ajax/FrameworkTest.php @@ -0,0 +1,158 @@ +drupalGetAjax('ajax-test/render'); + $expected = new SettingsCommand(['ajax' => 'test'], TRUE); + $this->assertCommand($commands, $expected->render(), 'JavaScript settings command is present.'); + } + + /** + * Tests AjaxResponse::prepare() AJAX commands ordering. + */ + public function testOrder() { + $expected_commands = []; + + // Expected commands, in a very specific order. + $asset_resolver = \Drupal::service('asset.resolver'); + $css_collection_renderer = \Drupal::service('asset.css.collection_renderer'); + $js_collection_renderer = \Drupal::service('asset.js.collection_renderer'); + $renderer = \Drupal::service('renderer'); + $build['#attached']['library'][] = 'ajax_test/order-css-command'; + $assets = AttachedAssets::createFromRenderArray($build); + $css_render_array = $css_collection_renderer->render($asset_resolver->getCssAssets($assets, FALSE)); + $expected_commands[1] = new AddCssCommand($renderer->renderRoot($css_render_array)); + $build['#attached']['library'][] = 'ajax_test/order-header-js-command'; + $build['#attached']['library'][] = 'ajax_test/order-footer-js-command'; + $assets = AttachedAssets::createFromRenderArray($build); + list($js_assets_header, $js_assets_footer) = $asset_resolver->getJsAssets($assets, FALSE); + $js_header_render_array = $js_collection_renderer->render($js_assets_header); + $js_footer_render_array = $js_collection_renderer->render($js_assets_footer); + $expected_commands[2] = new PrependCommand('head', $js_header_render_array); + $expected_commands[3] = new AppendCommand('body', $js_footer_render_array); + $expected_commands[4] = new HtmlCommand('body', 'Hello, world!'); + + // Load any page with at least one CSS file, at least one JavaScript file + // and at least one #ajax-powered element. The latter is an assumption of + // drupalPostAjaxForm(), the two former are assumptions of the Ajax + // renderer. + // @todo refactor AJAX Framework + tests to make less assumptions. + $this->drupalGet('ajax_forms_test_lazy_load_form'); + + // Verify AJAX command order — this should always be the order: + // 1. CSS files + // 2. JavaScript files in the header + // 3. JavaScript files in the footer + // 4. Any other AJAX commands, in whatever order they were added. + $commands = $this->drupalGetAjax('ajax-test/order'); + $this->assertCommand(array_slice($commands, 0, 1), $expected_commands[1]->render()); + $this->assertCommand(array_slice($commands, 1, 1), $expected_commands[2]->render()); + $this->assertCommand(array_slice($commands, 2, 1), $expected_commands[3]->render()); + $this->assertCommand(array_slice($commands, 3, 1), $expected_commands[4]->render()); + } + + /** + * Tests the behavior of an error alert command. + */ + public function testAJAXRenderError() { + // Verify custom error message. + $edit = [ + 'message' => 'Custom error message.', + ]; + $commands = $this->drupalGetAjax('ajax-test/render-error', ['query' => $edit]); + $expected = new AlertCommand($edit['message']); + $this->assertCommand($commands, $expected->render(), 'Custom error message is output.'); + } + + /** + * Asserts the array of Ajax commands contains the searched command. + * + * An AjaxResponse object stores an array of Ajax commands. This array + * sometimes includes commands automatically provided by the framework in + * addition to commands returned by a particular controller. During testing, + * we're usually interested that a particular command is present, and don't + * care whether other commands precede or follow the one we're interested in. + * Additionally, the command we're interested in may include additional data + * that we're not interested in. Therefore, this function simply asserts that + * one of the commands in $haystack contains all of the keys and values in + * $needle. Furthermore, if $needle contains a 'settings' key with an array + * value, we simply assert that all keys and values within that array are + * present in the command we're checking, and do not consider it a failure if + * the actual command contains additional settings that aren't part of + * $needle. + * + * @param $haystack + * An array of rendered Ajax commands returned by the server. + * @param $needle + * Array of info we're expecting in one of those commands. + */ + protected function assertCommand($haystack, $needle) { + $found = FALSE; + foreach ($haystack as $command) { + // If the command has additional settings that we're not testing for, do + // not consider that a failure. + if (isset($command['settings']) && is_array($command['settings']) && isset($needle['settings']) && is_array($needle['settings'])) { + $command['settings'] = array_intersect_key($command['settings'], $needle['settings']); + } + // If the command has additional data that we're not testing for, do not + // consider that a failure. Also, == instead of ===, because we don't + // require the key/value pairs to be in any particular order + // (http://php.net/manual/language.operators.array.php). + if (array_intersect_key($command, $needle) == $needle) { + $found = TRUE; + break; + } + } + $this->assertTrue($found); + } + + /** + * Requests a path or URL in drupal_ajax format and JSON-decodes the response. + * + * @param \Drupal\Core\Url|string $path + * Drupal path or URL to request from. + * @param array $options + * Array of URL options. + * @param array $headers + * Array of headers. + * + * @return array + * Decoded JSON. + */ + protected function drupalGetAjax($path, array $options = [], array $headers = []) { + $headers[] = 'X-Requested-With: XMLHttpRequest'; + if (!isset($options['query'][MainContentViewSubscriber::WRAPPER_FORMAT])) { + $options['query'][MainContentViewSubscriber::WRAPPER_FORMAT] = 'drupal_ajax'; + } + return Json::decode($this->drupalGet($path, $options, $headers)); + } + +} diff --git a/web/core/modules/system/src/Tests/Form/ElementsTableSelectTest.php b/web/core/modules/system/tests/src/Functional/Form/ElementsTableSelectTest.php similarity index 65% rename from web/core/modules/system/src/Tests/Form/ElementsTableSelectTest.php rename to web/core/modules/system/tests/src/Functional/Form/ElementsTableSelectTest.php index 030526eef..f377c0abf 100644 --- a/web/core/modules/system/src/Tests/Form/ElementsTableSelectTest.php +++ b/web/core/modules/system/tests/src/Functional/Form/ElementsTableSelectTest.php @@ -1,17 +1,16 @@ drupalGet('form_test/tableselect/multiple-true'); - $this->assertNoText(t('Empty text.'), 'Empty text should not be displayed.'); + $this->assertSession()->responseNotContains('Empty text.', 'Empty text should not be displayed.'); // Test for the presence of the Select all rows tableheader. - $this->assertFieldByXPath('//th[@class="select-all"]', NULL, 'Presence of the "Select all" checkbox.'); + $this->assertNotEmpty($this->xpath('//th[@class="select-all"]'), 'Presence of the "Select all" checkbox.'); $rows = ['row1', 'row2', 'row3']; foreach ($rows as $row) { - $this->assertFieldByXPath('//input[@type="checkbox"]', $row, format_string('Checkbox for value @row.', ['@row' => $row])); - } - } - - /** - * Test the presence of ajax functionality for all options. - */ - public function testAjax() { - $rows = ['row1', 'row2', 'row3']; - // Test checkboxes (#multiple == TRUE). - foreach ($rows as $row) { - $element = 'tableselect[' . $row . ']'; - $edit = [$element => TRUE]; - $result = $this->drupalPostAjaxForm('form_test/tableselect/multiple-true', $edit, $element); - $this->assertFalse(empty($result), t('Ajax triggers on checkbox for @row.', ['@row' => $row])); - } - // Test radios (#multiple == FALSE). - $element = 'tableselect'; - foreach ($rows as $row) { - $edit = [$element => $row]; - $result = $this->drupalPostAjaxForm('form_test/tableselect/multiple-false', $edit, $element); - $this->assertFalse(empty($result), t('Ajax triggers on radio for @row.', ['@row' => $row])); + $this->assertNotEmpty($this->xpath('//input[@type="checkbox"]', [$row]), "Checkbox for the value $row."); } } @@ -65,40 +43,39 @@ class ElementsTableSelectTest extends WebTestBase { public function testMultipleFalse() { $this->drupalGet('form_test/tableselect/multiple-false'); - $this->assertNoText(t('Empty text.'), 'Empty text should not be displayed.'); + $this->assertSession()->pageTextNotContains('Empty text.'); // Test for the absence of the Select all rows tableheader. - $this->assertNoFieldByXPath('//th[@class="select-all"]', '', 'Absence of the "Select all" checkbox.'); + $this->assertFalse($this->xpath('//th[@class="select-all"]')); $rows = ['row1', 'row2', 'row3']; foreach ($rows as $row) { - $this->assertFieldByXPath('//input[@type="radio"]', $row, format_string('Radio button for value @row.', ['@row' => $row])); + $this->assertNotEmpty($this->xpath('//input[@type="radio"]', [$row], "Radio button value: $row")); } } /** * Tests the display when #colspan is set. */ - public function testTableselectColSpan() { + public function testTableSelectColSpan() { $this->drupalGet('form_test/tableselect/colspan'); - $this->assertText(t('Three'), 'Presence of the third column'); - $this->assertNoText(t('Four'), 'Absence of a fourth column'); + $this->assertSession()->pageTextContains('Three', 'Presence of the third column'); + $this->assertSession()->pageTextNotContains('Four', 'Absence of a fourth column'); // There should be three labeled column headers and 1 for the input. - $table_head = $this->xpath('//thead'); - $this->assertEqual(count($table_head[0]->tr->th), 4, 'There are four column headers'); + $table_head = $this->xpath('//thead/tr/th'); + $this->assertEquals(count($table_head), 4, 'There are four column headers'); - $table_body = $this->xpath('//tbody'); // The first two body rows should each have 5 table cells: One for the // radio, one cell in the first column, one cell in the second column, // and two cells in the third column which has colspan 2. for ($i = 0; $i <= 1; $i++) { - $this->assertEqual(count($table_body[0]->tr[$i]->td), 5, format_string('There are five cells in row @row.', ['@row' => $i])); + $this->assertEquals(count($this->xpath('//tbody/tr[' . ($i + 1) . ']/td')), 5, 'There are five cells in row ' . $i); } // The third row should have 3 cells, one for the radio, one spanning the // first and second column, and a third in column 3 (which has colspan 3). - $this->assertEqual(count($table_body[0]->tr[2]->td), 3, 'There are three cells in row 3.'); + $this->assertEquals(count($this->xpath('//tbody/tr[3]/td')), 3, 'There are three cells in row 3.'); } /** @@ -106,7 +83,7 @@ class ElementsTableSelectTest extends WebTestBase { */ public function testEmptyText() { $this->drupalGet('form_test/tableselect/empty-text'); - $this->assertText(t('Empty text.'), 'Empty text should be displayed.'); + $this->assertSession()->pageTextContains('Empty text.', 'Empty text should be displayed.'); } /** @@ -119,18 +96,19 @@ class ElementsTableSelectTest extends WebTestBase { $edit['tableselect[row1]'] = TRUE; $this->drupalPostForm('form_test/tableselect/multiple-true', $edit, 'Submit'); - $this->assertText(t('Submitted: row1 = row1'), 'Checked checkbox row1'); - $this->assertText(t('Submitted: row2 = 0'), 'Unchecked checkbox row2.'); - $this->assertText(t('Submitted: row3 = 0'), 'Unchecked checkbox row3.'); + $assert_session = $this->assertSession(); + $assert_session->pageTextContains('Submitted: row1 = row1', 'Checked checkbox row1'); + $assert_session->pageTextContains('Submitted: row2 = 0', 'Unchecked checkbox row2.'); + $assert_session->pageTextContains('Submitted: row3 = 0', 'Unchecked checkbox row3.'); // Test a submission with multiple checkboxes checked. $edit['tableselect[row1]'] = TRUE; $edit['tableselect[row3]'] = TRUE; $this->drupalPostForm('form_test/tableselect/multiple-true', $edit, 'Submit'); - $this->assertText(t('Submitted: row1 = row1'), 'Checked checkbox row1.'); - $this->assertText(t('Submitted: row2 = 0'), 'Unchecked checkbox row2.'); - $this->assertText(t('Submitted: row3 = row3'), 'Checked checkbox row3.'); + $assert_session->pageTextContains('Submitted: row1 = row1', 'Checked checkbox row1.'); + $assert_session->pageTextContains('Submitted: row2 = 0', 'Unchecked checkbox row2.'); + $assert_session->pageTextContains('Submitted: row3 = row3', 'Checked checkbox row3.'); } @@ -140,7 +118,7 @@ class ElementsTableSelectTest extends WebTestBase { public function testMultipleFalseSubmit() { $edit['tableselect'] = 'row1'; $this->drupalPostForm('form_test/tableselect/multiple-false', $edit, 'Submit'); - $this->assertText(t('Submitted: row1'), 'Selected radio button'); + $this->assertSession()->pageTextContains('Submitted: row1', 'Selected radio button'); } /** @@ -149,18 +127,18 @@ class ElementsTableSelectTest extends WebTestBase { public function testAdvancedSelect() { // When #multiple = TRUE a Select all checkbox should be displayed by default. $this->drupalGet('form_test/tableselect/advanced-select/multiple-true-default'); - $this->assertFieldByXPath('//th[@class="select-all"]', NULL, 'Display a "Select all" checkbox by default when #multiple is TRUE.'); + $this->xpath('//th[@class="select-all"]'); // When #js_select is set to FALSE, a "Select all" checkbox should not be displayed. $this->drupalGet('form_test/tableselect/advanced-select/multiple-true-no-advanced-select'); - $this->assertNoFieldByXPath('//th[@class="select-all"]', NULL, 'Do not display a "Select all" checkbox when #js_select is FALSE.'); + $this->assertFalse($this->xpath('//th[@class="select-all"]')); // A "Select all" checkbox never makes sense when #multiple = FALSE, regardless of the value of #js_select. $this->drupalGet('form_test/tableselect/advanced-select/multiple-false-default'); - $this->assertNoFieldByXPath('//th[@class="select-all"]', NULL, 'Do not display a "Select all" checkbox when #multiple is FALSE.'); + $this->assertFalse($this->xpath('//th[@class="select-all"]')); $this->drupalGet('form_test/tableselect/advanced-select/multiple-false-advanced-select'); - $this->assertNoFieldByXPath('//th[@class="select-all"]', NULL, 'Do not display a "Select all" checkbox when #multiple is FALSE, even when #js_select is TRUE.'); + $this->assertFalse($this->xpath('//th[@class="select-all"]')); } /** diff --git a/web/core/modules/system/tests/src/Functional/Form/RebuildTest.php b/web/core/modules/system/tests/src/Functional/Form/RebuildTest.php new file mode 100644 index 000000000..3e951e2e8 --- /dev/null +++ b/web/core/modules/system/tests/src/Functional/Form/RebuildTest.php @@ -0,0 +1,61 @@ +drupalCreateContentType(['type' => 'page', 'name' => 'Basic page']); + + $this->webUser = $this->drupalCreateUser(['access content']); + $this->drupalLogin($this->webUser); + } + + /** + * Tests preservation of values. + */ + public function testRebuildPreservesValues() { + $edit = [ + 'checkbox_1_default_off' => TRUE, + 'checkbox_1_default_on' => FALSE, + 'text_1' => 'foo', + ]; + $this->drupalPostForm('form-test/form-rebuild-preserve-values', $edit, 'Add more'); + + $assert_session = $this->assertSession(); + + // Verify that initial elements retained their submitted values. + $assert_session->checkboxChecked('edit-checkbox-1-default-off'); + $assert_session->checkboxNotChecked('edit-checkbox-1-default-on'); + $assert_session->fieldValueEquals('edit-text-1', 'foo'); + + // Verify that newly added elements were initialized with their default values. + $assert_session->checkboxChecked('edit-checkbox-2-default-on'); + $assert_session->checkboxNotChecked('edit-checkbox-2-default-off'); + $assert_session->fieldValueEquals('edit-text-2', 'DEFAULT 2'); + } + +} diff --git a/web/core/modules/system/src/Tests/Form/StorageTest.php b/web/core/modules/system/tests/src/Functional/Form/StorageTest.php similarity index 61% rename from web/core/modules/system/src/Tests/Form/StorageTest.php rename to web/core/modules/system/tests/src/Functional/Form/StorageTest.php index a1f7fee3d..95969fde5 100644 --- a/web/core/modules/system/src/Tests/Form/StorageTest.php +++ b/web/core/modules/system/tests/src/Functional/Form/StorageTest.php @@ -1,8 +1,10 @@ drupalGet('form_test/form-storage'); - $this->assertText('Form constructions: 1'); + + $assert_session = $this->assertSession(); + $assert_session->pageTextContains('Form constructions: 1'); $edit = ['title' => 'new', 'value' => 'value_is_set']; // Use form rebuilding triggered by a submit button. $this->drupalPostForm(NULL, $edit, 'Continue submit'); - $this->assertText('Form constructions: 2'); - $this->assertText('Form constructions: 3'); + $assert_session->pageTextContains('Form constructions: 2'); + $assert_session->pageTextContains('Form constructions: 3'); // Reset the form to the values of the storage, using a form rebuild // triggered by button of type button. $this->drupalPostForm(NULL, ['title' => 'changed'], 'Reset'); - $this->assertFieldByName('title', 'new', 'Values have been reset.'); + $assert_session->fieldValueEquals('title', 'new'); // After rebuilding, the form has been cached. - $this->assertText('Form constructions: 4'); + $assert_session->pageTextContains('Form constructions: 4'); $this->drupalPostForm(NULL, $edit, 'Save'); - $this->assertText('Form constructions: 4'); - $this->assertText('Title: new', 'The form storage has stored the values.'); + $assert_session->pageTextContains('Form constructions: 4'); + $assert_session->pageTextContains('Title: new', 'The form storage has stored the values.'); } /** @@ -62,26 +69,26 @@ class StorageTest extends WebTestBase { */ public function testFormCached() { $this->drupalGet('form_test/form-storage', ['query' => ['cache' => 1]]); - $this->assertText('Form constructions: 1'); + $this->assertSession()->pageTextContains('Form constructions: 1'); $edit = ['title' => 'new', 'value' => 'value_is_set']; // Use form rebuilding triggered by a submit button. $this->drupalPostForm(NULL, $edit, 'Continue submit'); // The first one is for the building of the form. - $this->assertText('Form constructions: 2'); + $this->assertSession()->pageTextContains('Form constructions: 2'); // The second one is for the rebuilding of the form. - $this->assertText('Form constructions: 3'); + $this->assertSession()->pageTextContains('Form constructions: 3'); // Reset the form to the values of the storage, using a form rebuild // triggered by button of type button. $this->drupalPostForm(NULL, ['title' => 'changed'], 'Reset'); - $this->assertFieldByName('title', 'new', 'Values have been reset.'); - $this->assertText('Form constructions: 4'); + $this->assertSession()->fieldValueEquals('title', 'new'); + $this->assertSession()->pageTextContains('Form constructions: 4'); $this->drupalPostForm(NULL, $edit, 'Save'); - $this->assertText('Form constructions: 4'); - $this->assertText('Title: new', 'The form storage has stored the values.'); + $this->assertSession()->pageTextContains('Form constructions: 4'); + $this->assertSession()->pageTextContains('Title: new', 'The form storage has stored the values.'); } /** @@ -124,7 +131,7 @@ class StorageTest extends WebTestBase { // validation error. Post again and verify that the rebuilt form contains // the values of the updated form storage. $this->drupalPostForm(NULL, ['title' => 'foo', 'value' => 'bar'], 'Save'); - $this->assertText("The thing has been changed.", 'The altered form storage value was updated in cache and taken over.'); + $this->assertSession()->pageTextContains("The thing has been changed.", 'The altered form storage value was updated in cache and taken over.'); } /** @@ -135,27 +142,27 @@ class StorageTest extends WebTestBase { // Request the form with 'cache' query parameter to enable form caching. $this->drupalGet('form_test/form-storage', ['query' => ['cache' => 1, 'immutable' => 1]]); $buildIdFields = $this->xpath('//input[@name="form_build_id"]'); - $this->assertEqual(count($buildIdFields), 1, 'One form build id field on the page'); - $buildId = (string) $buildIdFields[0]['value']; + $this->assertEquals(count($buildIdFields), 1, 'One form build id field on the page'); + $buildId = $buildIdFields[0]->getValue(); // Trigger validation error by submitting an empty title. $edit = ['title' => '']; $this->drupalPostForm(NULL, $edit, 'Continue submit'); // Verify that the build-id did change. - $this->assertNoFieldByName('form_build_id', $buildId, 'Build id changes when form validation fails'); + $this->assertSession()->hiddenFieldValueNotEquals('form_build_id', $buildId); // Retrieve the new build-id. $buildIdFields = $this->xpath('//input[@name="form_build_id"]'); - $this->assertEqual(count($buildIdFields), 1, 'One form build id field on the page'); - $buildId = (string) $buildIdFields[0]['value']; + $this->assertEquals(count($buildIdFields), 1, 'One form build id field on the page'); + $buildId = (string) $buildIdFields[0]->getValue(); // Trigger validation error by again submitting an empty title. $edit = ['title' => '']; $this->drupalPostForm(NULL, $edit, 'Continue submit'); // Verify that the build-id does not change the second time. - $this->assertFieldByName('form_build_id', $buildId, 'Build id remains the same when form validation fails subsequently'); + $this->assertSession()->hiddenFieldValueEquals('form_build_id', $buildId); } /** @@ -164,25 +171,28 @@ class StorageTest extends WebTestBase { public function testImmutableFormLegacyProtection() { $this->drupalGet('form_test/form-storage', ['query' => ['cache' => 1, 'immutable' => 1]]); $build_id_fields = $this->xpath('//input[@name="form_build_id"]'); - $this->assertEqual(count($build_id_fields), 1, 'One form build id field on the page'); - $build_id = (string) $build_id_fields[0]['value']; + $this->assertEquals(count($build_id_fields), 1, 'One form build id field on the page'); + $build_id = $build_id_fields[0]->getValue(); // Try to poison the form cache. - $original = $this->drupalGetAjax('form-test/form-storage-legacy/' . $build_id); - $this->assertEqual($original['form']['#build_id_old'], $build_id, 'Original build_id was recorded'); - $this->assertNotEqual($original['form']['#build_id'], $build_id, 'New build_id was generated'); + $response = $this->drupalGet('form-test/form-storage-legacy/' . $build_id, ['query' => [MainContentViewSubscriber::WRAPPER_FORMAT => 'drupal_ajax']], ['X-Requested-With: XMLHttpRequest']); + $original = json_decode($response, TRUE); + + $this->assertEquals($original['form']['#build_id_old'], $build_id, 'Original build_id was recorded'); + $this->assertNotEquals($original['form']['#build_id'], $build_id, 'New build_id was generated'); // Assert that a watchdog message was logged by // \Drupal::formBuilder()->setCache(). - $status = (bool) db_query_range('SELECT 1 FROM {watchdog} WHERE message = :message', 0, 1, [':message' => 'Form build-id mismatch detected while attempting to store a form in the cache.']); - $this->assert($status, 'A watchdog message was logged by \Drupal::formBuilder()->setCache'); + $status = (bool) Database::getConnection()->queryRange('SELECT 1 FROM {watchdog} WHERE message = :message', 0, 1, [':message' => 'Form build-id mismatch detected while attempting to store a form in the cache.']); + $this->assertTrue($status, 'A watchdog message was logged by \Drupal::formBuilder()->setCache'); // Ensure that the form state was not poisoned by the preceding call. - $original = $this->drupalGetAjax('form-test/form-storage-legacy/' . $build_id); - $this->assertEqual($original['form']['#build_id_old'], $build_id, 'Original build_id was recorded'); - $this->assertNotEqual($original['form']['#build_id'], $build_id, 'New build_id was generated'); - $this->assert(empty($original['form']['#poisoned']), 'Original form structure was preserved'); - $this->assert(empty($original['form_state']['poisoned']), 'Original form state was preserved'); + $response = $this->drupalGet('form-test/form-storage-legacy/' . $build_id, ['query' => [MainContentViewSubscriber::WRAPPER_FORMAT => 'drupal_ajax']], ['X-Requested-With: XMLHttpRequest']); + $original = json_decode($response, TRUE); + $this->assertEquals($original['form']['#build_id_old'], $build_id, 'Original build_id was recorded'); + $this->assertNotEquals($original['form']['#build_id'], $build_id, 'New build_id was generated'); + $this->assertTrue(empty($original['form']['#poisoned']), 'Original form structure was preserved'); + $this->assertTrue(empty($original['form_state']['poisoned']), 'Original form state was preserved'); } } diff --git a/web/core/modules/system/tests/src/Functional/Mail/MailTest.php b/web/core/modules/system/tests/src/Functional/Mail/MailTest.php index 6f3253924..ef59d0128 100644 --- a/web/core/modules/system/tests/src/Functional/Mail/MailTest.php +++ b/web/core/modules/system/tests/src/Functional/Mail/MailTest.php @@ -107,6 +107,32 @@ class MailTest extends BrowserTestBase { $this->assertEquals('Drépal this is a very long test sentence to te ', Unicode::mimeHeaderDecode($sent_message['headers']['From']), 'From header is correctly encoded.'); $this->assertFalse(isset($sent_message['headers']['Reply-to']), 'Message reply-to is not set if not specified.'); $this->assertFalse(isset($sent_message['headers']['Errors-To']), 'Errors-to header must not be set, it is deprecated.'); + + // Test RFC-2822 rules are respected for 'display-name' component of + // 'From:' header. Specials characters are not allowed, so randomly add one + // of them to the site name and check the string is wrapped in quotes. Also + // hardcode some double-quotes and backslash to validate these are escaped + // properly too. + $specials = '()<>[]:;@\,."'; + $site_name = 'Drupal' . $specials[rand(0, strlen($specials) - 1)] . ' "si\te"'; + $this->config('system.site')->set('name', $site_name)->save(); + // Send an email and check that the From-header contains the site name + // within double-quotes. Also make sure double-quotes and "\" are escaped. + \Drupal::service('plugin.manager.mail')->mail('simpletest', 'from_test', 'from_test@example.com', $language); + $captured_emails = \Drupal::state()->get('system.test_mail_collector'); + $sent_message = end($captured_emails); + $escaped_site_name = str_replace(['\\', '"'], ['\\\\', '\\"'], $site_name); + $this->assertEquals('"' . $escaped_site_name . '" ', $sent_message['headers']['From'], 'From header is correctly quoted.'); + + // Make sure display-name is not quoted nor escaped if part on an encoding. + $site_name = 'Drépal, "si\te"'; + $this->config('system.site')->set('name', $site_name)->save(); + // Send an email and check that the From-header contains the site name. + \Drupal::service('plugin.manager.mail')->mail('simpletest', 'from_test', 'from_test@example.com', $language); + $captured_emails = \Drupal::state()->get('system.test_mail_collector'); + $sent_message = end($captured_emails); + $this->assertEquals('=?UTF-8?B?RHLDqXBhbCwgInNpXHRlIg==?= ', $sent_message['headers']['From'], 'From header is correctly encoded.'); + $this->assertEquals($site_name . ' ', Unicode::mimeHeaderDecode($sent_message['headers']['From']), 'From header is correctly encoded.'); } /** diff --git a/web/core/modules/system/src/Tests/Routing/RouterTest.php b/web/core/modules/system/tests/src/Functional/Routing/RouterTest.php similarity index 79% rename from web/core/modules/system/src/Tests/Routing/RouterTest.php rename to web/core/modules/system/tests/src/Functional/Routing/RouterTest.php index 8d7c43e86..0cb10bd80 100644 --- a/web/core/modules/system/src/Tests/Routing/RouterTest.php +++ b/web/core/modules/system/tests/src/Functional/Routing/RouterTest.php @@ -1,11 +1,11 @@ drupalGet('router_test/test1'); $this->assertRaw('test1', 'The correct string was returned because the route was successful.'); // Check expected headers from FinishResponseSubscriber. - $headers = $this->drupalGetHeaders(); - $this->assertEqual($headers['x-ua-compatible'], 'IE=edge'); - $this->assertEqual($headers['content-language'], 'en'); - $this->assertEqual($headers['x-content-type-options'], 'nosniff'); - $this->assertEqual($headers['x-frame-options'], 'SAMEORIGIN'); + $headers = $this->getSession()->getResponseHeaders(); + + $this->assertEquals($headers['X-UA-Compatible'], ['IE=edge']); + $this->assertEquals($headers['Content-language'], ['en']); + $this->assertEquals($headers['X-Content-Type-Options'], ['nosniff']); + $this->assertEquals($headers['X-Frame-Options'], ['SAMEORIGIN']); $this->drupalGet('router_test/test2'); $this->assertRaw('test2', 'The correct string was returned because the route was successful.'); // Check expected headers from FinishResponseSubscriber. $headers = $this->drupalGetHeaders(); - $this->assertEqual($headers['x-drupal-cache-contexts'], implode(' ', $expected_cache_contexts)); - $this->assertEqual($headers['x-drupal-cache-tags'], 'config:user.role.anonymous http_response rendered'); + $this->assertEqual($headers['X-Drupal-Cache-Contexts'], [implode(' ', $expected_cache_contexts)]); + $this->assertEqual($headers['X-Drupal-Cache-Tags'], ['config:user.role.anonymous http_response rendered']); // Confirm that the page wrapping is being added, so we're not getting a // raw body returned. $this->assertRaw('', 'Page markup was found.'); // In some instances, the subrequest handling may get confused and render // a page inception style. This test verifies that is not happening. - $this->assertNoPattern('#.*#s', 'There was no double-page effect from a misrendered subrequest.'); + $this->assertSession()->responseNotMatches('#.*#s', 'There was no double-page effect from a misrendered subrequest.'); // Confirm that route-level access check's cacheability is applied to the // X-Drupal-Cache-Contexts and X-Drupal-Cache-Tags headers. // 1. controller result: render array, globally cacheable route access. $this->drupalGet('router_test/test18'); $headers = $this->drupalGetHeaders(); - $this->assertEqual($headers['x-drupal-cache-contexts'], implode(' ', Cache::mergeContexts($renderer_required_cache_contexts, ['url']))); - $this->assertEqual($headers['x-drupal-cache-tags'], 'config:user.role.anonymous foo http_response rendered'); + $this->assertEqual($headers['X-Drupal-Cache-Contexts'], [implode(' ', Cache::mergeContexts($renderer_required_cache_contexts, ['url']))]); + $this->assertEqual($headers['X-Drupal-Cache-Tags'], ['config:user.role.anonymous foo http_response rendered']); // 2. controller result: render array, per-role cacheable route access. $this->drupalGet('router_test/test19'); $headers = $this->drupalGetHeaders(); - $this->assertEqual($headers['x-drupal-cache-contexts'], implode(' ', Cache::mergeContexts($renderer_required_cache_contexts, ['url', 'user.roles']))); - $this->assertEqual($headers['x-drupal-cache-tags'], 'config:user.role.anonymous foo http_response rendered'); + $this->assertEqual($headers['X-Drupal-Cache-Contexts'], [implode(' ', Cache::mergeContexts($renderer_required_cache_contexts, ['url', 'user.roles']))]); + $this->assertEqual($headers['X-Drupal-Cache-Tags'], ['config:user.role.anonymous foo http_response rendered']); // 3. controller result: Response object, globally cacheable route access. $this->drupalGet('router_test/test1'); $headers = $this->drupalGetHeaders(); - $this->assertFalse(isset($headers['x-drupal-cache-contexts'])); - $this->assertFalse(isset($headers['x-drupal-cache-tags'])); + $this->assertFalse(isset($headers['X-Drupal-Cache-Contexts'])); + $this->assertFalse(isset($headers['X-Drupal-Cache-Tags'])); // 4. controller result: Response object, per-role cacheable route access. $this->drupalGet('router_test/test20'); $headers = $this->drupalGetHeaders(); - $this->assertFalse(isset($headers['x-drupal-cache-contexts'])); - $this->assertFalse(isset($headers['x-drupal-cache-tags'])); + $this->assertFalse(isset($headers['X-Drupal-Cache-Contexts'])); + $this->assertFalse(isset($headers['X-Drupal-Cache-Tags'])); // 5. controller result: CacheableResponse object, globally cacheable route access. $this->drupalGet('router_test/test21'); $headers = $this->drupalGetHeaders(); - $this->assertEqual($headers['x-drupal-cache-contexts'], ''); - $this->assertEqual($headers['x-drupal-cache-tags'], 'http_response'); + $this->assertEqual($headers['X-Drupal-Cache-Contexts'], ['']); + $this->assertEqual($headers['X-Drupal-Cache-Tags'], ['http_response']); // 6. controller result: CacheableResponse object, per-role cacheable route access. $this->drupalGet('router_test/test22'); $headers = $this->drupalGetHeaders(); - $this->assertEqual($headers['x-drupal-cache-contexts'], 'user.roles'); - $this->assertEqual($headers['x-drupal-cache-tags'], 'http_response'); + $this->assertEqual($headers['X-Drupal-Cache-Contexts'], ['user.roles']); + $this->assertEqual($headers['X-Drupal-Cache-Tags'], ['http_response']); // Finally, verify that the X-Drupal-Cache-Contexts and X-Drupal-Cache-Tags // headers are not sent when their container parameter is set to FALSE. $this->drupalGet('router_test/test18'); $headers = $this->drupalGetHeaders(); - $this->assertTrue(isset($headers['x-drupal-cache-contexts'])); - $this->assertTrue(isset($headers['x-drupal-cache-tags'])); - $this->setHttpResponseDebugCacheabilityHeaders(FALSE); + $this->assertTrue(isset($headers['X-Drupal-Cache-Contexts'])); + $this->assertTrue(isset($headers['X-Drupal-Cache-Tags'])); + $this->setContainerParameter('http.response.debug_cacheability_headers', FALSE); + $this->rebuildContainer(); + $this->resetAll(); $this->drupalGet('router_test/test18'); $headers = $this->drupalGetHeaders(); - $this->assertFalse(isset($headers['x-drupal-cache-contexts'])); - $this->assertFalse(isset($headers['x-drupal-cache-tags'])); + $this->assertFalse(isset($headers['X-Drupal-Cache-Contexts'])); + $this->assertFalse(isset($headers['X-Drupal-Cache-Tags'])); } /** @@ -145,7 +148,7 @@ class RouterTest extends WebTestBase { // In some instances, the subrequest handling may get confused and render // a page inception style. This test verifies that is not happening. - $this->assertNoPattern('#.*#s', 'There was no double-page effect from a misrendered subrequest.'); + $this->assertSession()->responseNotMatches('#.*#s', 'There was no double-page effect from a misrendered subrequest.'); } /** @@ -162,7 +165,7 @@ class RouterTest extends WebTestBase { // In some instances, the subrequest handling may get confused and render // a page inception style. This test verifies that is not happening. - $this->assertNoPattern('#.*#s', 'There was no double-page effect from a misrendered subrequest.'); + $this->assertSession()->responseNotMatches('#.*#s', 'There was no double-page effect from a misrendered subrequest.'); } /** @@ -179,7 +182,7 @@ class RouterTest extends WebTestBase { // In some instances, the subrequest handling may get confused and render // a page inception style. This test verifies that is not happening. - $this->assertNoPattern('#.*#s', 'There was no double-page effect from a misrendered subrequest.'); + $this->assertSession()->responseNotMatches('#.*#s', 'There was no double-page effect from a misrendered subrequest.'); } /** @@ -208,7 +211,7 @@ class RouterTest extends WebTestBase { // In some instances, the subrequest handling may get confused and render // a page inception style. This test verifies that is not happening. - $this->assertNoPattern('#.*#s', 'There was no double-page effect from a misrendered subrequest.'); + $this->assertSession()->responseNotMatches('#.*#s', 'There was no double-page effect from a misrendered subrequest.'); } /** @@ -277,7 +280,9 @@ class RouterTest extends WebTestBase { public function testControllerResolutionAjax() { // This will fail with a JSON parse error if the request is not routed to // The correct controller. - $this->drupalGetAjax('/router_test/test10'); + $options['query'][MainContentViewSubscriber::WRAPPER_FORMAT] = 'drupal_ajax'; + $headers[] = 'X-Requested-With: XMLHttpRequest'; + $this->drupalGet('/router_test/test10', $options, $headers); $this->assertEqual($this->drupalGetHeader('Content-Type'), 'application/json', 'Correct mime content type was returned'); @@ -311,21 +316,18 @@ class RouterTest extends WebTestBase { $request = $this->container->get('request_stack')->getCurrentRequest(); $url = $request->getUriForPath('//router_test/test1'); $this->drupalGet($url); - $this->assertEqual(1, $this->redirectCount, $url . " redirected to " . $this->url); $this->assertUrl($request->getUriForPath('/router_test/test1')); // It should not matter how many leading slashes are used and query strings // should be preserved. $url = $request->getUriForPath('/////////////////////////////////////////////////router_test/test1') . '?qs=test'; $this->drupalGet($url); - $this->assertEqual(1, $this->redirectCount, $url . " redirected to " . $this->url); $this->assertUrl($request->getUriForPath('/router_test/test1') . '?qs=test'); // Ensure that external URLs in destination query params are not redirected // to. $url = $request->getUriForPath('/////////////////////////////////////////////////router_test/test1') . '?qs=test&destination=http://www.example.com%5c@drupal8alt.test'; $this->drupalGet($url); - $this->assertEqual(1, $this->redirectCount, $url . " redirected to " . $this->url); $this->assertUrl($request->getUriForPath('/router_test/test1') . '?qs=test'); } diff --git a/web/core/modules/system/src/Tests/Session/SessionAuthenticationTest.php b/web/core/modules/system/tests/src/Functional/Session/SessionAuthenticationTest.php similarity index 77% rename from web/core/modules/system/src/Tests/Session/SessionAuthenticationTest.php rename to web/core/modules/system/tests/src/Functional/Session/SessionAuthenticationTest.php index ffdc08d19..10a35020e 100644 --- a/web/core/modules/system/src/Tests/Session/SessionAuthenticationTest.php +++ b/web/core/modules/system/tests/src/Functional/Session/SessionAuthenticationTest.php @@ -1,17 +1,17 @@ drupalGet($protected_url); + $session = $this->getSession(); $this->assertResponse(401, 'An anonymous user cannot access a route protected with basic authentication.'); // We should be able to access the route with basic authentication. - $this->basicAuthGet($protected_url, $this->user->getUsername(), $this->user->pass_raw); + $this->basicAuthGet($protected_url, $this->user->getAccountName(), $this->user->passRaw); $this->assertResponse(200, 'A route protected with basic authentication can be accessed by an authenticated user.'); // Check that the correct user is logged in. - $this->assertEqual($this->user->id(), json_decode($this->getRawContent())->user, 'The correct user is authenticated on a route with basic authentication.'); + $this->assertEqual($this->user->id(), json_decode($session->getPage()->getContent())->user, 'The correct user is authenticated on a route with basic authentication.'); + $session->restart(); // If we now try to access a page without basic authentication then we // should no longer be logged in. $this->drupalGet($unprotected_url); $this->assertResponse(200, 'An unprotected route can be accessed without basic authentication.'); - $this->assertFalse(json_decode($this->getRawContent())->user, 'The user is no longer authenticated after visiting a page without basic authentication.'); + $this->assertFalse(json_decode($session->getPage()->getContent())->user, 'The user is no longer authenticated after visiting a page without basic authentication.'); // If we access the protected page again without basic authentication we // should get 401 Unauthorized. @@ -113,20 +115,24 @@ class SessionAuthenticationTest extends WebTestBase { $no_cookie_url = Url::fromRoute('session_test.get_session_basic_auth'); // A route that is authorized with standard cookie authentication. - $cookie_url = ''; + $cookie_url = 'user/login'; // If we authenticate with a third party authentication system then no // session cookie should be set, the third party system is responsible for // sustaining the session. - $this->basicAuthGet($no_cookie_url, $this->user->getUsername(), $this->user->pass_raw); + $this->basicAuthGet($no_cookie_url, $this->user->getAccountName(), $this->user->passRaw); $this->assertResponse(200, 'The user is successfully authenticated using basic authentication.'); - $this->assertFalse($this->drupalGetHeader('set-cookie', TRUE), 'No cookie is set on a route protected with basic authentication.'); + $this->assertEmpty($this->getSessionCookies()); + // Mink stores some information in the session that breaks the next check if + // not reset. + $this->getSession()->restart(); // On the other hand, authenticating using Cookie sets a cookie. - $edit = ['name' => $this->user->getUsername(), 'pass' => $this->user->pass_raw]; + $this->drupalGet($cookie_url); + $this->assertEmpty($this->getSessionCookies()); + $edit = ['name' => $this->user->getAccountName(), 'pass' => $this->user->passRaw]; $this->drupalPostForm($cookie_url, $edit, t('Log in')); - $this->assertResponse(200, 'The user is successfully authenticated using cookie authentication.'); - $this->assertTrue($this->drupalGetHeader('set-cookie', TRUE), 'A cookie is set on a route protected with cookie authentication.'); + $this->assertNotEmpty($this->getSessionCookies()); } } diff --git a/web/core/modules/system/src/Tests/Session/SessionTest.php b/web/core/modules/system/tests/src/Functional/Session/SessionTest.php similarity index 89% rename from web/core/modules/system/src/Tests/Session/SessionTest.php rename to web/core/modules/system/tests/src/Functional/Session/SessionTest.php index 0f219c503..f0ce98cef 100644 --- a/web/core/modules/system/src/Tests/Session/SessionTest.php +++ b/web/core/modules/system/tests/src/Functional/Session/SessionTest.php @@ -1,15 +1,15 @@ drupalCreateUser(); // Enable sessions. - $this->sessionReset($user->id()); + $this->sessionReset(); - // Make sure the session cookie is set as HttpOnly. - $this->drupalLogin($user); + // Make sure the session cookie is set as HttpOnly. We can only test this in + // the header, with the test setup + // \GuzzleHttp\Cookie\SetCookie::getHttpOnly() always returns FALSE. + // Start a new session by setting a message. + $this->drupalGet('session-test/set-message'); + $this->assertSessionCookie(TRUE); $this->assertTrue(preg_match('/HttpOnly/i', $this->drupalGetHeader('Set-Cookie', TRUE)), 'Session cookie is set as HttpOnly.'); - $this->drupalLogout(); // Verify that the session is regenerated if a module calls exit // in hook_user_login(). @@ -49,15 +52,15 @@ class SessionTest extends WebTestBase { $user->save(); $this->drupalGet('session-test/id'); $matches = []; - preg_match('/\s*session_id:(.*)\n/', $this->getRawContent(), $matches); + preg_match('/\s*session_id:(.*)\n/', $this->getSession()->getPage()->getContent(), $matches); $this->assertTrue(!empty($matches[1]), 'Found session ID before logging in.'); $original_session = $matches[1]; // We cannot use $this->drupalLogin($user); because we exit in // session_test_user_login() which breaks a normal assertion. $edit = [ - 'name' => $user->getUsername(), - 'pass' => $user->pass_raw, + 'name' => $user->getAccountName(), + 'pass' => $user->passRaw, ]; $this->drupalPostForm('user/login', $edit, t('Log in')); $this->drupalGet('user'); @@ -66,7 +69,7 @@ class SessionTest extends WebTestBase { $this->drupalGet('session-test/id'); $matches = []; - preg_match('/\s*session_id:(.*)\n/', $this->getRawContent(), $matches); + preg_match('/\s*session_id:(.*)\n/', $this->getSession()->getPage()->getContent(), $matches); $this->assertTrue(!empty($matches[1]), 'Found session ID after logging in.'); $this->assertTrue($matches[1] != $original_session, 'Session ID changed after login.'); } @@ -91,14 +94,22 @@ class SessionTest extends WebTestBase { // properly, val_1 will still be set. $value_2 = $this->randomMachineName(); $this->drupalGet('session-test/no-set/' . $value_2); + $session = $this->getSession(); $this->assertText($value_2, 'The session value was correctly passed to session-test/no-set.', 'Session'); $this->drupalGet('session-test/get'); $this->assertText($value_1, 'Session data is not saved for drupal_save_session(FALSE).', 'Session'); // Switch browser cookie to anonymous user, then back to user 1. - $this->sessionReset(); - $this->sessionReset($user->id()); + $session_cookie_name = $this->getSessionName(); + $session_cookie_value = $session->getCookie($session_cookie_name); + $session->restart(); + $this->initFrontPage(); + // Session restart always resets all the cookies by design, so we need to + // add the old session cookie again. + $session->setCookie($session_cookie_name, $session_cookie_value); + $this->drupalGet('session-test/get'); $this->assertText($value_1, 'Session data persists through browser close.', 'Session'); + $this->mink->setDefaultSessionName('default'); // Logout the user and make sure the stored value no longer persists. $this->drupalLogout(); @@ -242,8 +253,6 @@ class SessionTest extends WebTestBase { $this->assertEqual($times4->timestamp, $times3->timestamp, 'Sessions table was not updated.'); // Force updating of users and sessions table once per second. - $this->settingsSet('session_write_interval', 0); - // Write that value also into the test settings.php file. $settings['settings']['session_write_interval'] = (object) [ 'value' => 0, 'required' => TRUE, @@ -270,8 +279,7 @@ class SessionTest extends WebTestBase { // Send a blank sid in the session cookie, and the session should no longer // be valid. Closing the curl handler will stop the previous session ID // from persisting. - $this->curlClose(); - $this->additionalCurlOptions[CURLOPT_COOKIE] = rawurlencode($this->getSessionName()) . '=;'; + $this->mink->resetSessions(); $this->drupalGet('session-test/id-from-cookie'); $this->assertRaw("session_id:\n", 'Session ID is blank as sent from cookie header.'); // Assert that we have an anonymous session now. @@ -281,19 +289,13 @@ class SessionTest extends WebTestBase { /** * Reset the cookie file so that it refers to the specified user. - * - * @param $uid - * User id to set as the active session. */ - public function sessionReset($uid = 0) { + public function sessionReset() { // Close the internal browser. - $this->curlClose(); + $this->mink->resetSessions(); $this->loggedInUser = FALSE; // Change cookie file for user. - $this->cookieFile = \Drupal::service('stream_wrapper_manager')->getViaScheme('temporary')->getDirectoryPath() . '/cookie.' . $uid . '.txt'; - $this->additionalCurlOptions[CURLOPT_COOKIEFILE] = $this->cookieFile; - $this->additionalCurlOptions[CURLOPT_COOKIESESSION] = TRUE; $this->drupalGet('session-test/get'); $this->assertResponse(200, 'Session test module is correctly enabled.', 'Session'); } @@ -303,10 +305,10 @@ class SessionTest extends WebTestBase { */ public function assertSessionCookie($sent) { if ($sent) { - $this->assertNotNull($this->sessionId, 'Session cookie was sent.'); + $this->assertNotEmpty($this->getSessionCookies()->count(), 'Session cookie was sent.'); } else { - $this->assertNull($this->sessionId, 'Session cookie was not sent.'); + $this->assertEmpty($this->getSessionCookies()->count(), 'Session cookie was not sent.'); } } diff --git a/web/core/modules/system/tests/src/Functional/Session/StackSessionHandlerIntegrationTest.php b/web/core/modules/system/tests/src/Functional/Session/StackSessionHandlerIntegrationTest.php new file mode 100644 index 000000000..d65ba4216 --- /dev/null +++ b/web/core/modules/system/tests/src/Functional/Session/StackSessionHandlerIntegrationTest.php @@ -0,0 +1,49 @@ +drupalGet('session-test/trace-handler', $options, $headers)); + $sessionId = $this->getSessionCookies()->getCookieByName($this->getSessionName())->getValue(); + $expect_trace = [ + ['BEGIN', 'test_argument', 'open'], + ['BEGIN', NULL, 'open'], + ['END', NULL, 'open'], + ['END', 'test_argument', 'open'], + ['BEGIN', 'test_argument', 'read', $sessionId], + ['BEGIN', NULL, 'read', $sessionId], + ['END', NULL, 'read', $sessionId], + ['END', 'test_argument', 'read', $sessionId], + ['BEGIN', 'test_argument', 'write', $sessionId], + ['BEGIN', NULL, 'write', $sessionId], + ['END', NULL, 'write', $sessionId], + ['END', 'test_argument', 'write', $sessionId], + ['BEGIN', 'test_argument', 'close'], + ['BEGIN', NULL, 'close'], + ['END', NULL, 'close'], + ['END', 'test_argument', 'close'], + ]; + $this->assertEqual($expect_trace, $actual_trace); + } + +} diff --git a/web/core/modules/system/tests/src/Functional/Theme/ThemeTest.php b/web/core/modules/system/tests/src/Functional/Theme/ThemeTest.php index 3d534d8e7..b70ee859c 100644 --- a/web/core/modules/system/tests/src/Functional/Theme/ThemeTest.php +++ b/web/core/modules/system/tests/src/Functional/Theme/ThemeTest.php @@ -94,7 +94,11 @@ class ThemeTest extends BrowserTestBase { $config->set('css.preprocess', 0); $config->save(); $this->drupalGet('theme-test/suggestion'); - $this->assertNoText('js.module.css', 'The theme\'s .info.yml file is able to override a module CSS file from being added to the page.'); + // We add a "?" to the assertion, because drupalSettings may include + // information about the file; we only really care about whether it appears + // in a LINK or STYLE tag, for which Drupal always adds a query string for + // cache control. + $this->assertSession()->responseNotContains('js.module.css?'); // Also test with aggregation enabled, simply ensuring no PHP errors are // triggered during drupal_build_css_cache() when a source file doesn't diff --git a/web/core/modules/system/tests/src/FunctionalJavascript/Form/ElementsTableSelectTest.php b/web/core/modules/system/tests/src/FunctionalJavascript/Form/ElementsTableSelectTest.php new file mode 100644 index 000000000..b565bfd74 --- /dev/null +++ b/web/core/modules/system/tests/src/FunctionalJavascript/Form/ElementsTableSelectTest.php @@ -0,0 +1,58 @@ +drupalGet('form_test/tableselect/multiple-true'); + $session = $this->getSession(); + $page = $session->getPage(); + for ($i = 1; $i <= 3; $i++) { + $row = 'row' . $i; + $page->hasUncheckedField($row); + $page->checkField($row); + $this->assertSession()->assertWaitOnAjaxRequest(); + // Check current row and previous rows are checked. + for ($j = 1; $j <= $i; $j++) { + $other_row = 'row' . $j; + $page->hasCheckedField($other_row); + } + } + + // Test radios (#multiple == FALSE). + $this->drupalGet('form_test/tableselect/multiple-false'); + for ($i = 1; $i <= 3; $i++) { + $row = 'input[value="row' . $i . '"]'; + $page->hasUncheckedField($row); + $this->click($row); + $this->assertSession()->assertWaitOnAjaxRequest(); + $page->hasCheckedField($row); + // Check other rows are not checked + for ($j = 1; $j <= 3; $j++) { + if ($j == $i) { + continue; + } + $other_row = 'edit-tableselect-row' . $j; + $page->hasUncheckedField($other_row); + } + } + } + +} diff --git a/web/core/modules/system/src/Tests/Form/RebuildTest.php b/web/core/modules/system/tests/src/FunctionalJavascript/Form/RebuildTest.php similarity index 53% rename from web/core/modules/system/src/Tests/Form/RebuildTest.php rename to web/core/modules/system/tests/src/FunctionalJavascript/Form/RebuildTest.php index 2e138bdfb..caf52094d 100644 --- a/web/core/modules/system/src/Tests/Form/RebuildTest.php +++ b/web/core/modules/system/tests/src/FunctionalJavascript/Form/RebuildTest.php @@ -1,12 +1,12 @@ drupalLogin($this->webUser); } - /** - * Tests preservation of values. - */ - public function testRebuildPreservesValues() { - $edit = [ - 'checkbox_1_default_off' => TRUE, - 'checkbox_1_default_on' => FALSE, - 'text_1' => 'foo', - ]; - $this->drupalPostForm('form-test/form-rebuild-preserve-values', $edit, 'Add more'); - - // Verify that initial elements retained their submitted values. - $this->assertFieldChecked('edit-checkbox-1-default-off', 'A submitted checked checkbox retained its checked state during a rebuild.'); - $this->assertNoFieldChecked('edit-checkbox-1-default-on', 'A submitted unchecked checkbox retained its unchecked state during a rebuild.'); - $this->assertFieldById('edit-text-1', 'foo', 'A textfield retained its submitted value during a rebuild.'); - - // Verify that newly added elements were initialized with their default values. - $this->assertFieldChecked('edit-checkbox-2-default-on', 'A newly added checkbox was initialized with a default checked state.'); - $this->assertNoFieldChecked('edit-checkbox-2-default-off', 'A newly added checkbox was initialized with a default unchecked state.'); - $this->assertFieldById('edit-text-2', 'DEFAULT 2', 'A newly added textfield was initialized with its default value.'); - } - /** * Tests that a form's action is retained after an Ajax submission. * @@ -68,6 +47,7 @@ class RebuildTest extends WebTestBase { * followed by a non-Ajax submission, which triggers a validation error. */ public function testPreserveFormActionAfterAJAX() { + $page = $this->getSession()->getPage(); // Create a multi-valued field for 'page' nodes to use for Ajax testing. $field_name = 'field_ajax_test'; FieldStorageConfig::create([ @@ -81,8 +61,26 @@ class RebuildTest extends WebTestBase { 'entity_type' => 'node', 'bundle' => 'page', ])->save(); + + // Also create a file field to test server side validation error. + $field_file_name = 'field_file_test'; + FieldStorageConfig::create([ + 'field_name' => $field_file_name, + 'entity_type' => 'node', + 'type' => 'file', + 'cardinality' => 1, + ])->save(); + FieldConfig::create([ + 'field_name' => $field_file_name, + 'entity_type' => 'node', + 'bundle' => 'page', + 'label' => 'Test file', + 'required' => TRUE, + ])->save(); + entity_get_form_display('node', 'page', 'default') ->setComponent($field_name, ['type' => 'text_textfield']) + ->setComponent($field_file_name, ['type' => 'file_generic']) ->save(); // Log in a user who can create 'page' nodes. @@ -93,27 +91,31 @@ class RebuildTest extends WebTestBase { // submission and verify it worked by ensuring the updated page has two text // field items in the field for which we just added an item. $this->drupalGet('node/add/page'); - $this->drupalPostAjaxForm(NULL, [], ['field_ajax_test_add_more' => t('Add another item')], NULL, [], [], 'node-page-form'); - $this->assert(count($this->xpath('//div[contains(@class, "field--name-field-ajax-test")]//input[@type="text"]')) == 2, 'AJAX submission succeeded.'); + $page->find('css', '[value="Add another item"]')->click(); + $this->assertSession()->assertWaitOnAjaxRequest(); + $this->assertTrue(count($this->xpath('//div[contains(@class, "field--name-field-ajax-test")]//input[@type="text"]')) == 2, 'AJAX submission succeeded.'); - // Submit the form with the non-Ajax "Save" button, leaving the title field + // Submit the form with the non-Ajax "Save" button, leaving the file field // blank to trigger a validation error, and ensure that a validation error // occurred, because this test is for testing what happens when a form is // re-rendered without being re-built, which is what happens when there's - // a validation error. - $this->drupalPostForm(NULL, [], t('Save')); - $this->assertText('Title field is required.', 'Non-AJAX submission correctly triggered a validation error.'); + // a server side validation error. + $edit = [ + 'title[0][value]' => $this->randomString(), + ]; + $this->drupalPostForm(NULL, $edit, 'Save'); + $this->assertSession()->pageTextContains('Test file field is required.', 'Non-AJAX submission correctly triggered a validation error.'); // Ensure that the form contains two items in the multi-valued field, so we // know we're testing a form that was correctly retrieved from cache. - $this->assert(count($this->xpath('//form[contains(@id, "node-page-form")]//div[contains(@class, "js-form-item-field-ajax-test")]//input[@type="text"]')) == 2, 'Form retained its state from cache.'); + $this->assertTrue(count($this->xpath('//form[contains(@id, "node-page-form")]//div[contains(@class, "js-form-item-field-ajax-test")]//input[@type="text"]')) == 2, 'Form retained its state from cache.'); // Ensure that the form's action is correct. $forms = $this->xpath('//form[contains(@class, "node-page-form")]'); - $this->assertEqual(1, count($forms)); + $this->assertEquals(1, count($forms)); // Strip query params off the action before asserting. - $url = parse_url($forms[0]['action'])['path']; - $this->assertEqual(Url::fromRoute('node.add', ['node_type' => 'page'])->toString(), $url); + $url = parse_url($forms[0]->getAttribute('action'))['path']; + $this->assertEquals(Url::fromRoute('node.add', ['node_type' => 'page'])->toString(), $url); } } diff --git a/web/core/modules/system/tests/src/FunctionalJavascript/Form/TriggeringElementTest.php b/web/core/modules/system/tests/src/FunctionalJavascript/Form/TriggeringElementTest.php new file mode 100644 index 000000000..41733ec46 --- /dev/null +++ b/web/core/modules/system/tests/src/FunctionalJavascript/Form/TriggeringElementTest.php @@ -0,0 +1,111 @@ +drupalGet($path); + + $assert_session = $this->assertSession(); + $this->getSession()->getDriver()->submitForm('//form[@id="' . $form_html_id . '"]'); + $assert_session->pageTextContains('There is no clicked button.'); + $assert_session->pageTextNotContains('Submit handler for form_test_clicked_button executed.'); + + // Ensure submitting a form with one or more submit buttons results in the + // triggering element being set to the first one the user has access to. An + // argument with 'r' in it indicates a restricted (#access=FALSE) button. + $this->drupalGet($path . '/s'); + $this->getSession()->getDriver()->submitForm('//form[@id="' . $form_html_id . '"]'); + $assert_session->pageTextContains('The clicked button is button1.'); + $assert_session->pageTextContains('Submit handler for form_test_clicked_button executed.'); + + $this->drupalGet($path . '/s/s'); + $this->getSession()->getDriver()->submitForm('//form[@id="' . $form_html_id . '"]'); + $assert_session->pageTextContains('The clicked button is button1.'); + $assert_session->pageTextContains('Submit handler for form_test_clicked_button executed.'); + + $this->drupalGet($path . '/rs/s'); + $this->getSession()->getDriver()->submitForm('//form[@id="' . $form_html_id . '"]'); + $assert_session->pageTextContains('The clicked button is button2.'); + $assert_session->pageTextContains('Submit handler for form_test_clicked_button executed.'); + + // Ensure submitting a form with buttons of different types results in the + // triggering element being set to the first button, regardless of type. For + // the FAPI 'button' type, this should result in the submit handler not + // executing. The types are 's'(ubmit), 'b'(utton), and 'i'(mage_button). + $this->drupalGet($path . '/s/b/i'); + $this->getSession()->getDriver()->submitForm('//form[@id="' . $form_html_id . '"]'); + $assert_session->pageTextContains('The clicked button is button1.'); + $assert_session->pageTextContains('Submit handler for form_test_clicked_button executed.'); + + $this->drupalGet($path . '/b/s/i'); + $this->getSession()->getDriver()->submitForm('//form[@id="' . $form_html_id . '"]'); + $assert_session->pageTextContains('The clicked button is button1.'); + $assert_session->pageTextNotContains('Submit handler for form_test_clicked_button executed.'); + + $this->drupalGet($path . '/i/s/b'); + $this->getSession()->getDriver()->submitForm('//form[@id="' . $form_html_id . '"]'); + $assert_session->pageTextContains('The clicked button is button1.'); + $assert_session->pageTextContains('Submit handler for form_test_clicked_button executed.'); + } + + /** + * Tests attempts to bypass access control. + * + * Test that the triggering element does not get set to a button with + * #access=FALSE. + */ + public function testAttemptAccessControlBypass() { + $path = 'form-test/clicked-button'; + $form_html_id = 'form-test-clicked-button'; + + // Retrieve a form where 'button1' has #access=FALSE and 'button2' doesn't. + $this->drupalGet($path . '/rs/s'); + + // Submit the form with 'button1=button1' in the POST data, which someone + // trying to get around security safeguards could easily do. We have to do + // a little trickery here, to work around the safeguards in drupalPostForm() + // by renaming the text field and value that is in the form to 'button1', + // we can get the data we want into \Drupal::request()->request. + $page = $this->getSession()->getPage(); + $input = $page->find('css', 'input[name="text"]'); + $this->assertNotNull($input, 'text input located.'); + + $input->setValue('name', 'button1'); + $input->setValue('value', 'button1'); + $this->xpath('//form[@id="' . $form_html_id . '"]//input[@type="submit"]')[0]->click(); + + // Ensure that the triggering element was not set to the restricted button. + // Do this with both a negative and positive assertion, because negative + // assertions alone can be brittle. See testNoButtonInfoInPost() for why the + // triggering element gets set to 'button2'. + $this->assertSession()->pageTextNotContains('The clicked button is button1.'); + $this->assertSession()->pageTextContains('The clicked button is button2.'); + } + +} diff --git a/web/core/modules/system/tests/src/FunctionalJavascript/FrameworkTest.php b/web/core/modules/system/tests/src/FunctionalJavascript/FrameworkTest.php new file mode 100644 index 000000000..60f10b35f --- /dev/null +++ b/web/core/modules/system/tests/src/FunctionalJavascript/FrameworkTest.php @@ -0,0 +1,122 @@ + 'ajax_forms_test_lazy_load_form_submit', + 'setting_value' => 'executed', + 'library_1' => 'system/admin', + 'library_2' => 'system/drupal.system', + ]; + + // Get the base page. + $this->drupalGet('ajax_forms_test_lazy_load_form'); + $page = $this->getSession()->getPage(); + $assert = $this->assertSession(); + + $original_settings = $this->getDrupalSettings(); + $original_libraries = explode(',', $original_settings['ajaxPageState']['libraries']); + + // Verify that the base page doesn't have the settings and files that are to + // be lazy loaded as part of the next requests. + $this->assertTrue(!isset($original_settings[$expected['setting_name']]), format_string('Page originally lacks the %setting, as expected.', ['%setting' => $expected['setting_name']])); + $this->assertTrue(!in_array($expected['library_1'], $original_libraries), format_string('Page originally lacks the %library library, as expected.', ['%library' => $expected['library_1']])); + $this->assertTrue(!in_array($expected['library_2'], $original_libraries), format_string('Page originally lacks the %library library, as expected.', ['%library' => $expected['library_2']])); + + // Submit the AJAX request without triggering files getting added. + $page->pressButton('Submit'); + $assert->assertWaitOnAjaxRequest(); + $new_settings = $this->getDrupalSettings(); + $new_libraries = explode(',', $new_settings['ajaxPageState']['libraries']); + + // Verify the setting was not added when not expected. + $this->assertTrue(!isset($new_settings[$expected['setting_name']]), format_string('Page still lacks the %setting, as expected.', ['%setting' => $expected['setting_name']])); + $this->assertTrue(!in_array($expected['library_1'], $new_libraries), format_string('Page still lacks the %library library, as expected.', ['%library' => $expected['library_1']])); + $this->assertTrue(!in_array($expected['library_2'], $new_libraries), format_string('Page still lacks the %library library, as expected.', ['%library' => $expected['library_2']])); + + // Submit the AJAX request and trigger adding files. + $page->checkField('add_files'); + $page->pressButton('Submit'); + $assert->assertWaitOnAjaxRequest(); + $new_settings = $this->getDrupalSettings(); + $new_libraries = explode(',', $new_settings['ajaxPageState']['libraries']); + + // Verify the expected setting was added, both to drupalSettings, and as + // the first AJAX command. + $this->assertIdentical($new_settings[$expected['setting_name']], $expected['setting_value'], format_string('Page now has the %setting.', ['%setting' => $expected['setting_name']])); + + // Verify the expected CSS file was added, both to drupalSettings, and as + // the second AJAX command for inclusion into the HTML. + $this->assertTrue(in_array($expected['library_1'], $new_libraries), format_string('Page state now has the %library library.', ['%library' => $expected['library_1']])); + + // Verify the expected JS file was added, both to drupalSettings, and as + // the third AJAX command for inclusion into the HTML. By testing for an + // exact HTML string containing the SCRIPT tag, we also ensure that + // unexpected JavaScript code, such as a jQuery.extend() that would + // potentially clobber rather than properly merge settings, didn't + // accidentally get added. + $this->assertTrue(in_array($expected['library_2'], $new_libraries), format_string('Page state now has the %library library.', ['%library' => $expected['library_2']])); + } + + /** + * Tests that drupalSettings.currentPath is not updated on AJAX requests. + */ + public function testCurrentPathChange() { + $this->drupalGet('ajax_forms_test_lazy_load_form'); + $page = $this->getSession()->getPage(); + $assert = $this->assertSession(); + + $old_settings = $this->getDrupalSettings(); + $page->pressButton('Submit'); + $assert->assertWaitOnAjaxRequest(); + $new_settings = $this->getDrupalSettings(); + $this->assertEquals($old_settings['path']['currentPath'], $new_settings['path']['currentPath']); + } + + /** + * Tests that overridden CSS files are not added during lazy load. + */ + public function testLazyLoadOverriddenCSS() { + // The test theme overrides js.module.css without an implementation, + // thereby removing it. + \Drupal::service('theme_handler')->install(['test_theme']); + $this->config('system.theme') + ->set('default', 'test_theme') + ->save(); + + // This gets the form, and does an Ajax submission on it. + $this->drupalGet('ajax_forms_test_lazy_load_form'); + $page = $this->getSession()->getPage(); + $assert = $this->assertSession(); + + $page->checkField('add_files'); + $page->pressButton('Submit'); + $assert->assertWaitOnAjaxRequest(); + + // Verify that the resulting HTML does not load the overridden CSS file. + // We add a "?" to the assertion, because drupalSettings may include + // information about the file; we only really care about whether it appears + // in a LINK or STYLE tag, for which Drupal always adds a query string for + // cache control. + $assert->responseNotContains('js.module.css?', 'Ajax lazy loading does not add overridden CSS files.'); + } + +} diff --git a/web/core/modules/system/tests/src/Kernel/Extension/ModuleHandlerTest.php b/web/core/modules/system/tests/src/Kernel/Extension/ModuleHandlerTest.php index 708d517a3..fd22de139 100644 --- a/web/core/modules/system/tests/src/Kernel/Extension/ModuleHandlerTest.php +++ b/web/core/modules/system/tests/src/Kernel/Extension/ModuleHandlerTest.php @@ -233,11 +233,11 @@ class ModuleHandlerTest extends KernelTestBase { $result = $this->moduleInstaller()->uninstall([$non_dependency]); $this->assertTrue($result, 'ModuleInstaller::uninstall() returns TRUE.'); $this->assertFalse($this->moduleHandler()->moduleExists($non_dependency)); - $this->assertEquals(drupal_get_installed_schema_version($non_dependency), SCHEMA_UNINSTALLED, "$dependency module was uninstalled."); + $this->assertEquals(drupal_get_installed_schema_version($non_dependency), SCHEMA_UNINSTALLED, "$non_dependency module was uninstalled."); // Verify that the installation profile itself was not uninstalled. $uninstalled_modules = \Drupal::state()->get('module_test.uninstall_order') ?: []; - $this->assertContains($non_dependency, $uninstalled_modules, "$dependency module is in the list of uninstalled modules."); + $this->assertContains($non_dependency, $uninstalled_modules, "$non_dependency module is in the list of uninstalled modules."); $this->assertNotContains($profile, $uninstalled_modules, 'The installation profile is not in the list of uninstalled modules.'); // Try uninstalling the required module. diff --git a/web/core/modules/system/tests/themes/test_theme/test_theme.info.yml b/web/core/modules/system/tests/themes/test_theme/test_theme.info.yml index b73a45d7c..b9660a45f 100644 --- a/web/core/modules/system/tests/themes/test_theme/test_theme.info.yml +++ b/web/core/modules/system/tests/themes/test_theme/test_theme.info.yml @@ -16,7 +16,7 @@ base theme: classy core: 8.x logo: images/logo2.svg stylesheets-remove: - - '@system/css/js.module.css' + - '@stable/css/system/components/js.module.css' libraries: - test_theme/global-styling libraries-override: diff --git a/web/core/modules/system/tests/themes/test_theme_settings/config/schema/test_theme_settings.schema.yml b/web/core/modules/system/tests/themes/test_theme_settings/config/schema/test_theme_settings.schema.yml index a53e94a0c..482577f2d 100644 --- a/web/core/modules/system/tests/themes/test_theme_settings/config/schema/test_theme_settings.schema.yml +++ b/web/core/modules/system/tests/themes/test_theme_settings/config/schema/test_theme_settings.schema.yml @@ -10,3 +10,9 @@ test_theme_settings.settings: sequence: type: integer label: 'fids' + multi_file: + type: sequence + label: 'Multiple file field with all file extensions' + sequence: + type: integer + label: 'fids' diff --git a/web/core/modules/system/tests/themes/test_theme_settings/theme-settings.php b/web/core/modules/system/tests/themes/test_theme_settings/theme-settings.php index 7f3c1395f..1255c6537 100644 --- a/web/core/modules/system/tests/themes/test_theme_settings/theme-settings.php +++ b/web/core/modules/system/tests/themes/test_theme_settings/theme-settings.php @@ -24,6 +24,17 @@ function test_theme_settings_form_system_theme_settings_alter(&$form, FormStateI ], ]; + $form['multi_file'] = [ + '#type' => 'managed_file', + '#title' => t('Multiple file field with all file extensions'), + '#multiple' => TRUE, + '#default_value' => theme_get_setting('multi_file'), + '#upload_location' => 'public://test', + '#upload_validators' => [ + 'file_validate_extensions' => [], + ], + ]; + $form['#submit'][] = 'test_theme_settings_form_system_theme_settings_submit'; } diff --git a/web/core/modules/taxonomy/src/Plugin/migrate/source/d6/TermLocalizedTranslation.php b/web/core/modules/taxonomy/src/Plugin/migrate/source/d6/TermLocalizedTranslation.php new file mode 100644 index 000000000..5348ee23e --- /dev/null +++ b/web/core/modules/taxonomy/src/Plugin/migrate/source/d6/TermLocalizedTranslation.php @@ -0,0 +1,100 @@ +addField('td', 'language', 'td.language'); + + // Add in the property, which is either name or description. + // Cast td.tid as char for PostgreSQL compatibility. + $query->leftJoin('i18n_strings', 'i18n', 'CAST(td.tid AS CHAR(255)) = i18n.objectid'); + $query->isNotNull('i18n.lid'); + $query->addField('i18n', 'lid'); + $query->addField('i18n', 'property'); + + // Add in the translation for the property. + $query->innerJoin('locales_target', 'lt', 'i18n.lid = lt.lid'); + $query->addField('lt', 'language', 'lt.language'); + $query->addField('lt', 'translation'); + return $query; + } + + /** + * {@inheritdoc} + */ + public function prepareRow(Row $row) { + $language = $row->getSourceProperty('ltlanguage'); + $row->setSourceProperty('language', $language); + $tid = $row->getSourceProperty('tid'); + + // If this row has been migrated it is a duplicate then skip it. + if ($this->idMap->lookupDestinationIds(['tid' => $tid, 'language' => $language])) { + return FALSE; + } + + // Save the translation for this property. + $property = $row->getSourceProperty('property'); + $row->setSourceProperty($property . '_translated', $row->getSourceProperty('translation')); + + // Get the translation, if one exists, for the property not already in the + // row. + $other_property = ($property == 'name') ? 'description' : 'name'; + $query = $this->select('i18n_strings', 'i18n') + ->fields('i18n', ['lid']) + ->condition('i18n.property', $other_property) + ->condition('i18n.objectid', $tid); + $query->leftJoin('locales_target', 'lt', 'i18n.lid = lt.lid'); + $query->condition('lt.language', $language); + $query->addField('lt', 'translation'); + $results = $query->execute()->fetchAssoc(); + $row->setSourceProperty($other_property . '_translated', $results['translation']); + + parent::prepareRow($row); + } + + /** + * {@inheritdoc} + */ + public function fields() { + $fields = [ + 'language' => $this->t('Language for this term.'), + 'name_translated' => $this->t('Term name translation.'), + 'description_translated' => $this->t('Term description translation.'), + ]; + return parent::fields() + $fields; + } + + /** + * {@inheritdoc} + */ + public function getIds() { + $ids['language']['type'] = 'string'; + $ids['language']['alias'] = 'lt'; + return parent::getIds() + $ids; + } + +} diff --git a/web/core/modules/taxonomy/tests/src/Kernel/Migrate/d6/MigrateTaxonomyTermTest.php b/web/core/modules/taxonomy/tests/src/Kernel/Migrate/d6/MigrateTaxonomyTermTest.php index 75c79241e..20ecb6390 100644 --- a/web/core/modules/taxonomy/tests/src/Kernel/Migrate/d6/MigrateTaxonomyTermTest.php +++ b/web/core/modules/taxonomy/tests/src/Kernel/Migrate/d6/MigrateTaxonomyTermTest.php @@ -104,7 +104,14 @@ class MigrateTaxonomyTermTest extends MigrateDrupal6TestBase { $this->assertArrayHasKey($tid, $tree_terms, "Term $tid exists in vocabulary tree"); $tree_term = $tree_terms[$tid]; - $this->assertEquals($values['parent'], $tree_term->parents, "Term $tid has correct parents in vocabulary tree"); + + // PostgreSQL, MySQL and SQLite may not return the parent terms in the + // same order so sort before testing. + $expected_parents = $values['parent']; + sort($expected_parents); + $actual_parents = $tree_term->parents; + sort($actual_parents); + $this->assertEquals($expected_parents, $actual_parents, "Term $tid has correct parents in vocabulary tree"); } } diff --git a/web/core/modules/taxonomy/tests/src/Kernel/Migrate/d6/MigrateTermLocalizedTranslationTest.php b/web/core/modules/taxonomy/tests/src/Kernel/Migrate/d6/MigrateTermLocalizedTranslationTest.php new file mode 100644 index 000000000..b051ce689 --- /dev/null +++ b/web/core/modules/taxonomy/tests/src/Kernel/Migrate/d6/MigrateTermLocalizedTranslationTest.php @@ -0,0 +1,142 @@ +installEntitySchema('taxonomy_term'); + $this->installConfig(static::$modules); + $this->executeMigrations([ + 'language', + 'd6_node_type', + 'd6_field', + 'd6_taxonomy_vocabulary', + 'd6_field_instance', + 'd6_taxonomy_term', + 'd6_taxonomy_term_localized_translation', + ]); + } + + /** + * Validates a migrated term contains the expected values. + * + * @param int $id + * Entity ID to load and check. + * @param string $expected_language + * The language code for this term. + * @param string $expected_label + * The label the migrated entity should have. + * @param string $expected_vid + * The parent vocabulary the migrated entity should have. + * @param string $expected_description + * The description the migrated entity should have. + * @param string $expected_format + * The format the migrated entity should have. + * @param int $expected_weight + * The weight the migrated entity should have. + * @param array $expected_parents + * The parent terms the migrated entity should have. + * @param int $expected_field_integer_value + * The value the migrated entity field should have. + * @param int $expected_term_reference_tid + * The term reference ID the migrated entity field should have. + */ + protected function assertEntity($id, $expected_language, $expected_label, $expected_vid, $expected_description = '', $expected_format = NULL, $expected_weight = 0, array $expected_parents = [], $expected_field_integer_value = NULL, $expected_term_reference_tid = NULL) { + /** @var \Drupal\taxonomy\TermInterface $entity */ + $entity = Term::load($id); + $this->assertInstanceOf(TermInterface::class, $entity); + $this->assertSame($expected_language, $entity->language()->getId()); + $this->assertSame($expected_label, $entity->label()); + $this->assertSame($expected_vid, $entity->bundle()); + $this->assertSame($expected_description, $entity->getDescription()); + $this->assertSame($expected_format, $entity->getFormat()); + $this->assertSame($expected_weight, $entity->getWeight()); + $this->assertHierarchy($expected_vid, $id, $expected_parents); + } + + /** + * Asserts that a term is present in the tree storage, with the right parents. + * + * @param string $vid + * Vocabulary ID. + * @param int $tid + * ID of the term to check. + * @param array $parent_ids + * The expected parent term IDs. + */ + protected function assertHierarchy($vid, $tid, array $parent_ids) { + if (!isset($this->treeData[$vid])) { + $tree = \Drupal::entityTypeManager()->getStorage('taxonomy_term')->loadTree($vid); + $this->treeData[$vid] = []; + foreach ($tree as $item) { + $this->treeData[$vid][$item->tid] = $item; + } + } + + $this->assertArrayHasKey($tid, $this->treeData[$vid], "Term $tid exists in taxonomy tree"); + $term = $this->treeData[$vid][$tid]; + $this->assertEquals($parent_ids, array_filter($term->parents), "Term $tid has correct parents in taxonomy tree"); + } + + /** + * Tests the Drupal 6 i18n localized taxonomy term to Drupal 8 migration. + */ + public function testTranslatedLocalizedTaxonomyTerms() { + $this->assertEntity(14, 'en', 'Talos IV', 'vocabulary_name_much_longer_than', 'The home of Captain Christopher Pike.', NULL, '0', []); + $this->assertEntity(15, 'en', 'Vulcan', 'vocabulary_name_much_longer_than', NULL, NULL, '0', []); + + /** @var \Drupal\taxonomy\TermInterface $entity */ + $entity = Term::load(14); + $this->assertTrue($entity->hasTranslation('fr')); + $translation = $entity->getTranslation('fr'); + $this->assertSame('fr - Talos IV', $translation->label()); + $this->assertSame('fr - The home of Captain Christopher Pike.', $translation->getDescription()); + + $this->assertTrue($entity->hasTranslation('zu')); + $translation = $entity->getTranslation('zu'); + $this->assertSame('Talos IV', $translation->label()); + $this->assertSame('zu - The home of Captain Christopher Pike.', $translation->getDescription()); + + $entity = Term::load(15); + $this->assertFalse($entity->hasTranslation('fr')); + $this->assertTrue($entity->hasTranslation('zu')); + $translation = $entity->getTranslation('zu'); + $this->assertSame('zu - Vulcan', $translation->label()); + $this->assertSame('', $translation->getDescription()); + } + +} diff --git a/web/core/modules/taxonomy/tests/src/Kernel/Migrate/d6/MigrateVocabularyFieldInstanceTest.php b/web/core/modules/taxonomy/tests/src/Kernel/Migrate/d6/MigrateVocabularyFieldInstanceTest.php index c29e3d1e8..46d60a639 100644 --- a/web/core/modules/taxonomy/tests/src/Kernel/Migrate/d6/MigrateVocabularyFieldInstanceTest.php +++ b/web/core/modules/taxonomy/tests/src/Kernel/Migrate/d6/MigrateVocabularyFieldInstanceTest.php @@ -81,13 +81,13 @@ class MigrateVocabularyFieldInstanceTest extends MigrateDrupal6TestBase { $field_id = 'node.story.field_vocabulary_3_i_2_'; $field = FieldConfig::load($field_id); $this->assertFalse($field->isRequired(), 'Field is not required'); - $this->assertFalse($field->isTranslatable()); + $this->assertTrue($field->isTranslatable()); // Tests that a vocabulary named like a D8 base field will be migrated and // prefixed with 'field_' to avoid conflicts. $field_type = FieldConfig::load('node.sponsor.field_type'); $this->assertInstanceOf(FieldConfig::class, $field_type); - $this->assertFalse($field->isTranslatable()); + $this->assertTrue($field->isTranslatable()); } /** diff --git a/web/core/modules/taxonomy/tests/src/Kernel/Plugin/migrate/source/d6/TermLocalizedTranslationTest.php b/web/core/modules/taxonomy/tests/src/Kernel/Plugin/migrate/source/d6/TermLocalizedTranslationTest.php new file mode 100644 index 000000000..c2fea26d1 --- /dev/null +++ b/web/core/modules/taxonomy/tests/src/Kernel/Plugin/migrate/source/d6/TermLocalizedTranslationTest.php @@ -0,0 +1,179 @@ + 1, + 'vid' => 5, + 'name' => 'name value 1', + 'description' => 'description value 1', + 'weight' => 0, + 'language' => NULL, + ], + [ + 'tid' => 2, + 'vid' => 6, + 'name' => 'name value 2', + 'description' => 'description value 2', + 'weight' => 0, + 'language' => NULL, + ], + [ + 'tid' => 3, + 'vid' => 6, + 'name' => 'name value 3', + 'description' => 'description value 3', + 'weight' => 0, + 'language' => NULL, + ], + [ + 'tid' => 4, + 'vid' => 5, + 'name' => 'name value 4', + 'description' => 'description value 4', + 'weight' => 1, + 'language' => NULL, + ], + ]; + $tests[0]['source_data']['term_hierarchy'] = [ + [ + 'tid' => 1, + 'parent' => 0, + ], + [ + 'tid' => 2, + 'parent' => 0, + ], + [ + 'tid' => 3, + 'parent' => 0, + ], + [ + 'tid' => 4, + 'parent' => 1, + ], + ]; + $tests[0]['source_data']['i18n_strings'] = [ + [ + 'lid' => 6, + 'objectid' => 1, + 'type' => 'term', + 'property' => 'name', + 'objectindex' => '1', + 'format' => 0, + ], + [ + 'lid' => 7, + 'objectid' => 1, + 'type' => 'term', + 'property' => 'description', + 'objectindex' => '1', + 'format' => 0, + ], + [ + 'lid' => 8, + 'objectid' => 3, + 'type' => 'term', + 'property' => 'name', + 'objectindex' => '3', + 'format' => 0, + ], + ]; + $tests[0]['source_data']['locales_target'] = [ + [ + 'lid' => 6, + 'language' => 'fr', + 'translation' => 'fr - name value 1 translation', + 'plid' => 0, + 'plural' => 0, + 'i18n_status' => 0, + ], + [ + 'lid' => 7, + 'language' => 'fr', + 'translation' => 'fr - description value 1 translation', + 'plid' => 0, + 'plural' => 0, + 'i18n_status' => 0, + ], + [ + 'lid' => 8, + 'language' => 'zu', + 'translation' => 'zu - description value 2 translation', + 'plid' => 0, + 'plural' => 0, + 'i18n_status' => 0, + ], + ]; + + // The expected results. + $tests[0]['expected_data'] = [ + [ + 'tid' => 1, + 'vid' => 5, + 'name' => 'name value 1', + 'description' => 'description value 1', + 'weight' => 0, + 'parent' => [0], + 'property' => 'name', + 'language' => 'fr', + 'name_translated' => 'fr - name value 1 translation', + 'description_translated' => 'fr - description value 1 translation', + ], + [ + 'tid' => 1, + 'vid' => 5, + 'name' => 'name value 1', + 'description' => 'description value 1', + 'weight' => 0, + 'parent' => [0], + 'property' => 'description', + 'language' => 'fr', + 'name_translated' => 'fr - name value 1 translation', + 'description_translated' => 'fr - description value 1 translation', + ], + [ + 'tid' => 3, + 'vid' => 6, + 'name' => 'name value 3', + 'description' => 'description value 3', + 'weight' => 0, + 'parent' => [0], + 'property' => 'name', + 'language' => 'zu', + 'name_translated' => 'zu - description value 2 translation', + 'description_translated' => NULL, + ], + ]; + + $tests[0]['expected_count'] = NULL; + // Empty configuration will return terms for all vocabularies. + $tests[0]['configuration'] = []; + + return $tests; + } + +} diff --git a/web/core/modules/user/user.api.php b/web/core/modules/user/user.api.php index 1fc0140fe..01e460233 100644 --- a/web/core/modules/user/user.api.php +++ b/web/core/modules/user/user.api.php @@ -110,7 +110,8 @@ function hook_user_cancel_methods_alter(&$methods) { * * Called by $account->getDisplayName() to allow modules to alter the username * that is displayed. Can be used to ensure user privacy in situations where - * $account->getDisplayName() is too revealing. + * $account->getDisplayName() is too revealing. This hook is invoked both for + * user entities and the anonymous user session object. * * @param string|Drupal\Component\Render\MarkupInterface $name * The username that is displayed for a user. If a hook implementation changes @@ -118,7 +119,14 @@ function hook_user_cancel_methods_alter(&$methods) { * the implementation to ensure the user's name is escaped properly. String * values will be autoescaped. * @param \Drupal\Core\Session\AccountInterface $account - * The user object on which the operation is being performed. + * The object on which the operation is being performed. This object may be a + * user entity. If the object is an implementation of UserInterface you can + * use instanceof operator before accessing user entity methods. For example: + * @code + * if ($account instanceof UserInterface) { + * // Access user entity methods. + * } + * @endcode * * @see \Drupal\Core\Session\AccountInterface::getDisplayName() * @see sanitization diff --git a/web/core/modules/views/src/Plugin/Block/ViewsBlockBase.php b/web/core/modules/views/src/Plugin/Block/ViewsBlockBase.php index 4e3020ca0..9ed369c04 100644 --- a/web/core/modules/views/src/Plugin/Block/ViewsBlockBase.php +++ b/web/core/modules/views/src/Plugin/Block/ViewsBlockBase.php @@ -105,6 +105,13 @@ abstract class ViewsBlockBase extends BlockBase implements ContainerFactoryPlugi return ['views_label' => '']; } + /** + * {@inheritdoc} + */ + public function getPreviewFallbackString() { + return $this->t('Placeholder for the "@view" views block', ['@view' => $this->view->storage->label()]); + } + /** * {@inheritdoc} */ diff --git a/web/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_click_sort_ajax.yml b/web/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_click_sort_ajax.yml new file mode 100644 index 000000000..514655f9f --- /dev/null +++ b/web/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_click_sort_ajax.yml @@ -0,0 +1,65 @@ +langcode: en +status: true +dependencies: { } +id: test_click_sort_ajax +module: views +description: '' +tag: '' +base_table: views_test_data +base_field: nid +core: '8' +display: + default: + display_options: + use_ajax: true + fields: + id: + id: id + table: views_test_data + field: id + label: ID + plugin_id: numeric + name: + id: name + table: views_test_data + field: name + label: Name + plugin_id: string + created: + id: created + table: views_test_data + field: created + label: created + plugin_id: field + type: timestamp + settings: + date_format: medium + custom_date_format: '' + timezone: '' + access: + type: none + cache: + type: tag + style: + type: table + options: + info: + id: + sortable: true + default_sort_order: asc + name: + sortable: true + default_sort_order: desc + created: + sortable: false + display_plugin: default + display_title: Master + id: default + position: 0 + page_1: + display_options: + path: test_click_sort + display_plugin: page + display_title: Page + id: page_1 + position: 0 diff --git a/web/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_mini_pager_ajax.yml b/web/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_mini_pager_ajax.yml new file mode 100644 index 000000000..c49ef340e --- /dev/null +++ b/web/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_mini_pager_ajax.yml @@ -0,0 +1,88 @@ +langcode: en +status: true +dependencies: + module: + - node + - user +id: test_mini_pager_ajax +label: test_mini_pager +module: views +description: '' +tag: '' +base_table: node_field_data +base_field: nid +core: 8.x +display: + default: + display_plugin: default + id: default + display_title: Master + position: null + display_options: + use_ajax: true + access: + type: perm + cache: + type: tag + query: + type: views_query + exposed_form: + type: basic + pager: + type: mini + options: + items_per_page: 3 + offset: 0 + id: 0 + total_pages: null + tags: + previous: '‹‹ test' + next: '›› test' + expose: + items_per_page: false + items_per_page_label: 'Items per page' + items_per_page_options: '5, 10, 25, 50' + items_per_page_options_all: false + items_per_page_options_all_label: '- All -' + offset: false + offset_label: Offset + style: + type: default + row: + type: 'entity:node' + options: + view_mode: teaser + fields: + title: + id: title + table: node_field_data + field: title + label: '' + alter: + alter_text: false + make_link: false + absolute: false + trim: false + word_boundary: false + ellipsis: false + strip_tags: false + html: false + hide_empty: false + empty_zero: false + plugin_id: field + entity_type: node + entity_field: title + filters: { } + sorts: + nid: + id: nid + table: node_field_data + field: nid + plugin_id: standard + order: ASC + entity_type: node + entity_field: nid + title: test_mini_pager + filter_groups: + operator: AND + groups: { } diff --git a/web/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_pager_full_ajax.yml b/web/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_pager_full_ajax.yml new file mode 100644 index 000000000..31b4a26ac --- /dev/null +++ b/web/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_pager_full_ajax.yml @@ -0,0 +1,37 @@ +langcode: en +status: true +dependencies: + module: + - node +id: test_pager_full_ajax +label: '' +module: views +description: '' +tag: '' +base_table: node_field_data +base_field: nid +core: '8' +display: + default: + display_options: + use_ajax: true + access: + type: none + cache: + type: tag + exposed_form: + type: basic + pager: + options: + id: 0 + items_per_page: 5 + offset: 0 + type: full + style: + type: default + row: + type: 'entity:node' + display_plugin: default + display_title: Master + id: default + position: 0 diff --git a/web/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_user_path.yml b/web/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_user_path.yml new file mode 100644 index 000000000..a5e629f06 --- /dev/null +++ b/web/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_user_path.yml @@ -0,0 +1,158 @@ +langcode: en +status: true +dependencies: + module: + - user +id: test_user_path +label: 'user break' +module: views +description: '' +tag: '' +base_table: users_field_data +base_field: uid +core: 8.x +display: + default: + display_plugin: default + id: default + display_title: Master + position: 0 + display_options: + access: + type: perm + options: + perm: 'access user profiles' + cache: + type: tag + options: { } + query: + type: views_query + options: + disable_sql_rewrite: false + distinct: false + replica: false + query_comment: '' + query_tags: { } + exposed_form: + type: basic + options: + submit_button: Toepassen + reset_button: false + reset_button_label: Reset + exposed_sorts_label: 'Sorteren op' + expose_sort_order: true + sort_asc_label: Oplopend + sort_desc_label: Aflopend + pager: + type: mini + options: + items_per_page: 10 + offset: 0 + id: 0 + total_pages: null + expose: + items_per_page: false + items_per_page_label: 'Items per pagina' + items_per_page_options: '5, 10, 25, 50' + items_per_page_options_all: false + items_per_page_options_all_label: '- Alle -' + offset: false + offset_label: Startpunt + tags: + previous: ‹‹ + next: ›› + style: + type: default + row: + type: fields + fields: + name: + id: name + table: users_field_data + field: name + entity_type: user + entity_field: name + label: '' + alter: + alter_text: false + make_link: false + absolute: false + trim: false + word_boundary: false + ellipsis: false + strip_tags: false + html: false + hide_empty: false + empty_zero: false + plugin_id: field + relationship: none + group_type: group + admin_label: '' + exclude: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: true + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_alter_empty: true + click_sort_column: value + type: user_name + settings: { } + group_column: value + group_columns: { } + group_rows: true + delta_limit: 0 + delta_offset: 0 + delta_reversed: false + delta_first_last: false + multi_type: separator + separator: ', ' + field_api_classes: false + filters: + status: + value: '1' + table: users_field_data + field: status + plugin_id: boolean + entity_type: user + entity_field: status + id: status + expose: + operator: '' + group: 1 + sorts: { } + title: 'user break' + header: { } + footer: { } + empty: { } + relationships: { } + arguments: { } + display_extenders: { } + cache_metadata: + max-age: -1 + contexts: + - 'languages:language_content' + - 'languages:language_interface' + - url.query_args + - user.permissions + tags: { } + page_1: + display_plugin: page + id: page_1 + display_title: Page + position: 1 + display_options: + display_extenders: { } + path: user/% + cache_metadata: + max-age: -1 + contexts: + - 'languages:language_content' + - 'languages:language_interface' + - url.query_args + - user.permissions + tags: { } diff --git a/web/core/modules/views/tests/src/Functional/UserPathTest.php b/web/core/modules/views/tests/src/Functional/UserPathTest.php new file mode 100644 index 000000000..27a0f27a9 --- /dev/null +++ b/web/core/modules/views/tests/src/Functional/UserPathTest.php @@ -0,0 +1,32 @@ +drupalGet('user/login'); + $this->assertSession()->statusCodeEquals(200); + } + +} diff --git a/web/core/modules/views_ui/src/Tests/PreviewTest.php b/web/core/modules/views_ui/src/Tests/PreviewTest.php deleted file mode 100644 index c8bd7bd14..000000000 --- a/web/core/modules/views_ui/src/Tests/PreviewTest.php +++ /dev/null @@ -1,386 +0,0 @@ -install(['contextual']); - $this->resetAll(); - - $this->drupalGet('admin/structure/views/view/test_preview/edit'); - $this->assertResponse(200); - $this->drupalPostForm(NULL, $edit = [], t('Update preview')); - - $elements = $this->xpath('//div[@id="views-live-preview"]//ul[contains(@class, :ul-class)]/li[contains(@class, :li-class)]', [':ul-class' => 'contextual-links', ':li-class' => 'filter-add']); - $this->assertEqual(count($elements), 1, 'The contextual link to add a new field is shown.'); - - $this->drupalPostForm(NULL, $edit = ['view_args' => '100'], t('Update preview')); - - // Test that area text and exposed filters are present and rendered. - $this->assertFieldByName('id', NULL, 'ID exposed filter field found.'); - $this->assertText('Test header text', 'Rendered header text found'); - $this->assertText('Test footer text', 'Rendered footer text found.'); - $this->assertText('Test empty text', 'Rendered empty text found.'); - } - - /** - * Tests arguments in the preview form. - */ - public function testPreviewUI() { - $this->drupalGet('admin/structure/views/view/test_preview/edit'); - $this->assertResponse(200); - - $this->drupalPostForm(NULL, $edit = [], t('Update preview')); - - $elements = $this->xpath('//div[@class = "view-content"]/div[contains(@class, views-row)]'); - $this->assertEqual(count($elements), 5); - - // Filter just the first result. - $this->drupalPostForm(NULL, $edit = ['view_args' => '1'], t('Update preview')); - - $elements = $this->xpath('//div[@class = "view-content"]/div[contains(@class, views-row)]'); - $this->assertEqual(count($elements), 1); - - // Filter for no results. - $this->drupalPostForm(NULL, $edit = ['view_args' => '100'], t('Update preview')); - - $elements = $this->xpath('//div[@class = "view-content"]/div[contains(@class, views-row)]'); - $this->assertEqual(count($elements), 0); - - // Test that area text and exposed filters are present and rendered. - $this->assertFieldByName('id', NULL, 'ID exposed filter field found.'); - $this->assertText('Test header text', 'Rendered header text found'); - $this->assertText('Test footer text', 'Rendered footer text found.'); - $this->assertText('Test empty text', 'Rendered empty text found.'); - - // Test feed preview. - $view = []; - $view['label'] = $this->randomMachineName(16); - $view['id'] = strtolower($this->randomMachineName(16)); - $view['page[create]'] = 1; - $view['page[title]'] = $this->randomMachineName(16); - $view['page[path]'] = $this->randomMachineName(16); - $view['page[feed]'] = 1; - $view['page[feed_properties][path]'] = $this->randomMachineName(16); - $this->drupalPostForm('admin/structure/views/add', $view, t('Save and edit')); - $this->clickLink(t('Feed')); - $this->drupalPostForm(NULL, [], t('Update preview')); - $result = $this->xpath('//div[@id="views-live-preview"]/pre'); - $this->assertTrue(strpos($result[0], '' . $view['page[title]'] . ''), 'The Feed RSS preview was rendered.'); - - // Test the non-default UI display options. - // Statistics only, no query. - $settings = \Drupal::configFactory()->getEditable('views.settings'); - $settings->set('ui.show.performance_statistics', TRUE)->save(); - $this->drupalGet('admin/structure/views/view/test_preview/edit'); - $this->drupalPostForm(NULL, $edit = ['view_args' => '100'], t('Update preview')); - $this->assertText(t('Query build time')); - $this->assertText(t('Query execute time')); - $this->assertText(t('View render time')); - $this->assertNoRaw('Query'); - - // Statistics and query. - $settings->set('ui.show.sql_query.enabled', TRUE)->save(); - $this->drupalPostForm(NULL, $edit = ['view_args' => '100'], t('Update preview')); - $this->assertText(t('Query build time')); - $this->assertText(t('Query execute time')); - $this->assertText(t('View render time')); - $this->assertRaw('Query'); - $query_string = <<assertEscaped($query_string); - - // Test that the statistics and query are rendered above the preview. - $this->assertTrue(strpos($this->getRawContent(), 'views-query-info') < strpos($this->getRawContent(), 'view-test-preview'), 'Statistics shown above the preview.'); - - // Test that statistics and query rendered below the preview. - $settings->set('ui.show.sql_query.where', 'below')->save(); - $this->drupalPostForm(NULL, $edit = ['view_args' => '100'], t('Update preview')); - $this->assertTrue(strpos($this->getRawContent(), 'view-test-preview') < strpos($this->getRawContent(), 'views-query-info'), 'Statistics shown below the preview.'); - - // Test that the preview title isn't double escaped. - $this->drupalPostForm("admin/structure/views/nojs/display/test_preview/default/title", $edit = ['title' => 'Double & escaped'], t('Apply')); - $this->drupalPostForm(NULL, [], t('Update preview')); - $elements = $this->xpath('//div[@id="views-live-preview"]/div[contains(@class, views-query-info)]//td[text()=:text]', [':text' => t('Double & escaped')]); - $this->assertEqual(1, count($elements)); - } - - /** - * Tests the taxonomy term preview AJAX. - * - * This tests a specific regression in the taxonomy term view preview. - * - * @see https://www.drupal.org/node/2452659 - */ - public function testTaxonomyAJAX() { - \Drupal::service('module_installer')->install(['taxonomy']); - $this->getPreviewAJAX('taxonomy_term', 'page_1', 0); - } - - /** - * Tests pagers in the preview form. - */ - public function testPreviewWithPagersUI() { - - // Create 11 nodes and make sure that everyone is returned. - $this->drupalCreateContentType(['type' => 'page']); - for ($i = 0; $i < 11; $i++) { - $this->drupalCreateNode(); - } - - // Test Full Pager. - $this->getPreviewAJAX('test_pager_full', 'default', 5); - - // Test that the pager is present and rendered. - $elements = $this->xpath('//ul[contains(@class, :class)]/li', [':class' => 'pager__items']); - $this->assertTrue(!empty($elements), 'Full pager found.'); - - // Verify elements and links to pages. - // We expect to find 5 elements: current page == 1, links to pages 2 and - // and 3, links to 'next >' and 'last >>' pages. - $this->assertClass($elements[0], 'is-active', 'Element for current page has .is-active class.'); - $this->assertTrue($elements[0]->a, 'Element for current page has link.'); - - $this->assertClass($elements[1], 'pager__item', 'Element for page 2 has .pager__item class.'); - $this->assertTrue($elements[1]->a, 'Link to page 2 found.'); - - $this->assertClass($elements[2], 'pager__item', 'Element for page 3 has .pager__item class.'); - $this->assertTrue($elements[2]->a, 'Link to page 3 found.'); - - $this->assertClass($elements[3], 'pager__item--next', 'Element for next page has .pager__item--next class.'); - $this->assertTrue($elements[3]->a, 'Link to next page found.'); - - $this->assertClass($elements[4], 'pager__item--last', 'Element for last page has .pager__item--last class.'); - $this->assertTrue($elements[4]->a, 'Link to last page found.'); - - // Navigate to next page. - $elements = $this->xpath('//li[contains(@class, :class)]/a', [':class' => 'pager__item--next']); - $this->clickPreviewLinkAJAX($elements[0]['href'], 5); - - // Test that the pager is present and rendered. - $elements = $this->xpath('//ul[contains(@class, :class)]/li', [':class' => 'pager__items']); - $this->assertTrue(!empty($elements), 'Full pager found.'); - - // Verify elements and links to pages. - // We expect to find 7 elements: links to '<< first' and '< previous' - // pages, link to page 1, current page == 2, link to page 3 and links - // to 'next >' and 'last >>' pages. - $this->assertClass($elements[0], 'pager__item--first', 'Element for first page has .pager__item--first class.'); - $this->assertTrue($elements[0]->a, 'Link to first page found.'); - - $this->assertClass($elements[1], 'pager__item--previous', 'Element for previous page has .pager__item--previous class.'); - $this->assertTrue($elements[1]->a, 'Link to previous page found.'); - - $this->assertClass($elements[2], 'pager__item', 'Element for page 1 has .pager__item class.'); - $this->assertTrue($elements[2]->a, 'Link to page 1 found.'); - - $this->assertClass($elements[3], 'is-active', 'Element for current page has .is-active class.'); - $this->assertTrue($elements[3]->a, 'Element for current page has link.'); - - $this->assertClass($elements[4], 'pager__item', 'Element for page 3 has .pager__item class.'); - $this->assertTrue($elements[4]->a, 'Link to page 3 found.'); - - $this->assertClass($elements[5], 'pager__item--next', 'Element for next page has .pager__item--next class.'); - $this->assertTrue($elements[5]->a, 'Link to next page found.'); - - $this->assertClass($elements[6], 'pager__item--last', 'Element for last page has .pager__item--last class.'); - $this->assertTrue($elements[6]->a, 'Link to last page found.'); - - // Test Mini Pager. - $this->getPreviewAJAX('test_mini_pager', 'default', 3); - - // Test that the pager is present and rendered. - $elements = $this->xpath('//ul[contains(@class, :class)]/li', [':class' => 'pager__items']); - $this->assertTrue(!empty($elements), 'Mini pager found.'); - - // Verify elements and links to pages. - // We expect to find current pages element with no link, next page element - // with a link, and not to find previous page element. - $this->assertClass($elements[0], 'is-active', 'Element for current page has .is-active class.'); - - $this->assertClass($elements[1], 'pager__item--next', 'Element for next page has .pager__item--next class.'); - $this->assertTrue($elements[1]->a, 'Link to next page found.'); - - // Navigate to next page. - $elements = $this->xpath('//li[contains(@class, :class)]/a', [':class' => 'pager__item--next']); - $this->clickPreviewLinkAJAX($elements[0]['href'], 3); - - // Test that the pager is present and rendered. - $elements = $this->xpath('//ul[contains(@class, :class)]/li', [':class' => 'pager__items']); - $this->assertTrue(!empty($elements), 'Mini pager found.'); - - // Verify elements and links to pages. - // We expect to find 3 elements: previous page with a link, current - // page with no link, and next page with a link. - $this->assertClass($elements[0], 'pager__item--previous', 'Element for previous page has .pager__item--previous class.'); - $this->assertTrue($elements[0]->a, 'Link to previous page found.'); - - $this->assertClass($elements[1], 'is-active', 'Element for current page has .is-active class.'); - $this->assertFalse(isset($elements[1]->a), 'Element for current page has no link.'); - - $this->assertClass($elements[2], 'pager__item--next', 'Element for next page has .pager__item--next class.'); - $this->assertTrue($elements[2]->a, 'Link to next page found.'); - } - - /** - * Tests the additional information query info area. - */ - public function testPreviewAdditionalInfo() { - \Drupal::service('module_installer')->install(['views_ui_test']); - $this->resetAll(); - - $this->drupalGet('admin/structure/views/view/test_preview/edit'); - $this->assertResponse(200); - - $this->drupalPostForm(NULL, $edit = [], t('Update preview')); - - // Check for implementation of hook_views_preview_info_alter(). - // @see views_ui_test.module - $elements = $this->xpath('//div[@id="views-live-preview"]/div[contains(@class, views-query-info)]//td[text()=:text]', [':text' => t('Test row count')]); - $this->assertEqual(count($elements), 1, 'Views Query Preview Info area altered.'); - // Check that additional assets are attached. - $this->assertTrue(strpos($this->getDrupalSettings()['ajaxPageState']['libraries'], 'views_ui_test/views_ui_test.test') !== FALSE, 'Attached library found.'); - $this->assertRaw('css/views_ui_test.test.css', 'Attached CSS asset found.'); - } - - /** - * Tests view validation error messages in the preview. - */ - public function testPreviewError() { - $this->drupalGet('admin/structure/views/view/test_preview_error/edit'); - $this->assertResponse(200); - - $this->drupalPostForm(NULL, $edit = [], t('Update preview')); - - $this->assertText('Unable to preview due to validation errors.', 'Preview error text found.'); - } - - /** - * Tests the link to sort in the preview form. - */ - public function testPreviewSortLink() { - - // Get the preview. - $this->getPreviewAJAX('test_click_sort', 'page_1', 0); - - // Test that the header label is present. - $elements = $this->xpath('//th[contains(@class, :class)]/a', [':class' => 'views-field views-field-name']); - $this->assertTrue(!empty($elements), 'The header label is present.'); - - // Verify link. - $this->assertLinkByHref('preview/page_1?_wrapper_format=drupal_ajax&order=name&sort=desc', 0, 'The output URL is as expected.'); - - // Click link to sort. - $this->clickPreviewLinkAJAX($elements[0]['href'], 0); - - // Test that the header label is present. - $elements = $this->xpath('//th[contains(@class, :class)]/a', [':class' => 'views-field views-field-name is-active']); - $this->assertTrue(!empty($elements), 'The header label is present.'); - - // Verify link. - $this->assertLinkByHref('preview/page_1?_wrapper_format=drupal_ajax&order=name&sort=asc', 0, 'The output URL is as expected.'); - } - - /** - * Get the preview form and force an AJAX preview update. - * - * @param string $view_name - * The view to test. - * @param string $panel_id - * The view panel to test. - * @param int $row_count - * The expected number of rows in the preview. - */ - protected function getPreviewAJAX($view_name, $panel_id, $row_count) { - $this->drupalGet('admin/structure/views/view/' . $view_name . '/preview/' . $panel_id); - $result = $this->drupalPostAjaxForm(NULL, [], ['op' => t('Update preview')]); - $this->assertPreviewAJAX($result, $row_count); - } - - /** - * Mimic clicking on a preview link. - * - * @param string $url - * The url to navigate to. - * @param int $row_count - * The expected number of rows in the preview. - */ - protected function clickPreviewLinkAJAX($url, $row_count) { - $content = $this->content; - $drupal_settings = $this->drupalSettings; - $ajax_settings = [ - 'wrapper' => 'views-preview-wrapper', - 'method' => 'replaceWith', - ]; - $url = $this->getAbsoluteUrl($url); - $post = ['js' => 'true'] + $this->getAjaxPageStatePostData(); - $result = Json::decode($this->drupalPost($url, '', $post, ['query' => [MainContentViewSubscriber::WRAPPER_FORMAT => 'drupal_ajax']])); - if (!empty($result)) { - $this->drupalProcessAjaxResponse($content, $result, $ajax_settings, $drupal_settings); - } - $this->assertPreviewAJAX($result, $row_count); - } - - /** - * Assert that the AJAX response contains expected data. - * - * @param array $result - * An array of AJAX commands. - * @param int $row_count - * The expected number of rows in the preview. - */ - protected function assertPreviewAJAX($result, $row_count) { - // Has AJAX callback replied with an insert command? If so, we can - // assume that the page content was updated with AJAX returned data. - $result_commands = []; - foreach ($result as $command) { - $result_commands[$command['command']] = $command; - } - $this->assertTrue(isset($result_commands['insert']), 'AJAX insert command received.'); - - // Test if preview contains the expected number of rows. - $elements = $this->xpath('//div[@class = "view-content"]/div[contains(@class, views-row)]'); - $this->assertEqual(count($elements), $row_count, 'Expected items found on page.'); - } - - /** - * Asserts that an element has a given class. - * - * @param \SimpleXMLElement $element - * The element to test. - * @param string $class - * The class to assert. - * @param string $message - * (optional) A verbose message to output. - */ - protected function assertClass(\SimpleXMLElement $element, $class, $message = NULL) { - if (!isset($message)) { - $message = "Class .$class found."; - } - $this->assertTrue(strpos($element['class'], $class) !== FALSE, $message); - } - -} diff --git a/web/core/modules/views_ui/tests/src/Functional/PreviewTest.php b/web/core/modules/views_ui/tests/src/Functional/PreviewTest.php new file mode 100644 index 000000000..b76e464c8 --- /dev/null +++ b/web/core/modules/views_ui/tests/src/Functional/PreviewTest.php @@ -0,0 +1,161 @@ +install(['contextual']); + $this->resetAll(); + + $this->drupalGet('admin/structure/views/view/test_preview/edit'); + $this->assertResponse(200); + $this->drupalPostForm(NULL, $edit = [], t('Update preview')); + + $elements = $this->xpath('//div[@id="views-live-preview"]//ul[contains(@class, :ul-class)]/li[contains(@class, :li-class)]', [':ul-class' => 'contextual-links', ':li-class' => 'filter-add']); + $this->assertEqual(count($elements), 1, 'The contextual link to add a new field is shown.'); + + $this->drupalPostForm(NULL, $edit = ['view_args' => '100'], t('Update preview')); + + // Test that area text and exposed filters are present and rendered. + $this->assertFieldByName('id', NULL, 'ID exposed filter field found.'); + $this->assertText('Test header text', 'Rendered header text found'); + $this->assertText('Test footer text', 'Rendered footer text found.'); + $this->assertText('Test empty text', 'Rendered empty text found.'); + } + + /** + * Tests arguments in the preview form. + */ + public function testPreviewUI() { + $this->drupalGet('admin/structure/views/view/test_preview/edit'); + $this->assertResponse(200); + + $this->drupalPostForm(NULL, $edit = [], t('Update preview')); + + $elements = $this->xpath('//div[@class = "view-content"]/div[contains(@class, views-row)]'); + $this->assertEqual(count($elements), 5); + + // Filter just the first result. + $this->drupalPostForm(NULL, $edit = ['view_args' => '1'], t('Update preview')); + + $elements = $this->xpath('//div[@class = "view-content"]/div[contains(@class, views-row)]'); + $this->assertEqual(count($elements), 1); + + // Filter for no results. + $this->drupalPostForm(NULL, $edit = ['view_args' => '100'], t('Update preview')); + + $elements = $this->xpath('//div[@class = "view-content"]/div[contains(@class, views-row)]'); + $this->assertEqual(count($elements), 0); + + // Test that area text and exposed filters are present and rendered. + $this->assertFieldByName('id', NULL, 'ID exposed filter field found.'); + $this->assertText('Test header text', 'Rendered header text found'); + $this->assertText('Test footer text', 'Rendered footer text found.'); + $this->assertText('Test empty text', 'Rendered empty text found.'); + + // Test feed preview. + $view = []; + $view['label'] = $this->randomMachineName(16); + $view['id'] = strtolower($this->randomMachineName(16)); + $view['page[create]'] = 1; + $view['page[title]'] = $this->randomMachineName(16); + $view['page[path]'] = $this->randomMachineName(16); + $view['page[feed]'] = 1; + $view['page[feed_properties][path]'] = $this->randomMachineName(16); + $this->drupalPostForm('admin/structure/views/add', $view, t('Save and edit')); + $this->clickLink(t('Feed')); + $this->drupalPostForm(NULL, [], t('Update preview')); + $result = $this->xpath('//div[@id="views-live-preview"]/pre'); + $this->assertTrue(strpos($result[0]->getText(), '' . $view['page[title]'] . ''), 'The Feed RSS preview was rendered.'); + + // Test the non-default UI display options. + // Statistics only, no query. + $settings = \Drupal::configFactory()->getEditable('views.settings'); + $settings->set('ui.show.performance_statistics', TRUE)->save(); + $this->drupalGet('admin/structure/views/view/test_preview/edit'); + $this->drupalPostForm(NULL, $edit = ['view_args' => '100'], t('Update preview')); + $this->assertText(t('Query build time')); + $this->assertText(t('Query execute time')); + $this->assertText(t('View render time')); + $this->assertNoRaw('Query'); + + // Statistics and query. + $settings->set('ui.show.sql_query.enabled', TRUE)->save(); + $this->drupalPostForm(NULL, $edit = ['view_args' => '100'], t('Update preview')); + $this->assertText(t('Query build time')); + $this->assertText(t('Query execute time')); + $this->assertText(t('View render time')); + $this->assertRaw('Query'); + $query_string = <<assertEscaped($query_string); + + // Test that the statistics and query are rendered above the preview. + $this->assertTrue(strpos($this->getSession()->getPage()->getContent(), 'views-query-info') < strpos($this->getSession()->getPage()->getContent(), 'view-test-preview'), 'Statistics shown above the preview.'); + + // Test that statistics and query rendered below the preview. + $settings->set('ui.show.sql_query.where', 'below')->save(); + $this->drupalPostForm(NULL, $edit = ['view_args' => '100'], t('Update preview')); + $this->assertTrue(strpos($this->getSession()->getPage()->getContent(), 'view-test-preview') < strpos($this->getSession()->getPage()->getContent(), 'views-query-info'), 'Statistics shown below the preview.'); + + // Test that the preview title isn't double escaped. + $this->drupalPostForm("admin/structure/views/nojs/display/test_preview/default/title", $edit = ['title' => 'Double & escaped'], t('Apply')); + $this->drupalPostForm(NULL, [], t('Update preview')); + $elements = $this->xpath('//div[@id="views-live-preview"]/div[contains(@class, views-query-info)]//td[text()=:text]', [':text' => 'Double & escaped']); + $this->assertEqual(1, count($elements)); + } + + /** + * Tests the additional information query info area. + */ + public function testPreviewAdditionalInfo() { + \Drupal::service('module_installer')->install(['views_ui_test']); + $this->resetAll(); + + $this->drupalGet('admin/structure/views/view/test_preview/edit'); + $this->assertResponse(200); + + $this->drupalPostForm(NULL, $edit = [], t('Update preview')); + + // Check for implementation of hook_views_preview_info_alter(). + // @see views_ui_test.module + $elements = $this->xpath('//div[@id="views-live-preview"]/div[contains(@class, views-query-info)]//td[text()=:text]', [':text' => 'Test row count']); + $this->assertEqual(count($elements), 1, 'Views Query Preview Info area altered.'); + // Check that additional assets are attached. + $this->assertTrue(strpos($this->getDrupalSettings()['ajaxPageState']['libraries'], 'views_ui_test/views_ui_test.test') !== FALSE, 'Attached library found.'); + $this->assertRaw('css/views_ui_test.test.css', 'Attached CSS asset found.'); + } + + /** + * Tests view validation error messages in the preview. + */ + public function testPreviewError() { + $this->drupalGet('admin/structure/views/view/test_preview_error/edit'); + $this->assertResponse(200); + + $this->drupalPostForm(NULL, $edit = [], t('Update preview')); + + $this->assertText('Unable to preview due to validation errors.', 'Preview error text found.'); + } + +} diff --git a/web/core/modules/views_ui/tests/src/FunctionalJavascript/PreviewTest.php b/web/core/modules/views_ui/tests/src/FunctionalJavascript/PreviewTest.php new file mode 100644 index 000000000..489a3748d --- /dev/null +++ b/web/core/modules/views_ui/tests/src/FunctionalJavascript/PreviewTest.php @@ -0,0 +1,304 @@ +enableViewsTestModule(); + + $admin_user = $this->drupalCreateUser([ + 'administer site configuration', + 'administer views', + 'administer nodes', + 'access content overview', + ]); + + // Disable automatic live preview to make the sequence of calls clearer. + \Drupal::configFactory()->getEditable('views.settings')->set('ui.always_live_preview', FALSE)->save(); + $this->drupalLogin($admin_user); + } + + /** + * Sets up the views_test_data.module. + * + * Because the schema of views_test_data.module is dependent on the test + * using it, it cannot be enabled normally. + */ + protected function enableViewsTestModule() { + // Define the schema and views data variable before enabling the test module. + \Drupal::state()->set('views_test_data_schema', $this->schemaDefinition()); + \Drupal::state()->set('views_test_data_views_data', $this->viewsData()); + + \Drupal::service('module_installer')->install(['views_test_data']); + $this->resetAll(); + $this->rebuildContainer(); + $this->container->get('module_handler')->reload(); + + // Load the test dataset. + $data_set = $this->dataSet(); + $query = Database::getConnection()->insert('views_test_data') + ->fields(array_keys($data_set[0])); + foreach ($data_set as $record) { + $query->values($record); + } + $query->execute(); + } + + /** + * Returns the schema definition. + * + * @internal + */ + protected function schemaDefinition() { + return ViewTestData::schemaDefinition(); + } + + /** + * Returns the views data definition. + */ + protected function viewsData() { + return ViewTestData::viewsData(); + } + + /** + * Returns a very simple test dataset. + */ + protected function dataSet() { + return ViewTestData::dataSet(); + } + + /** + * Tests the taxonomy term preview AJAX. + * + * This tests a specific regression in the taxonomy term view preview. + * + * @see https://www.drupal.org/node/2452659 + */ + public function testTaxonomyAJAX() { + \Drupal::service('module_installer')->install(['taxonomy']); + $this->getPreviewAJAX('taxonomy_term', 'page_1', 0); + } + + /** + * Tests pagers in the preview form. + */ + public function testPreviewWithPagersUI() { + // Create 11 nodes and make sure that everyone is returned. + $this->drupalCreateContentType(['type' => 'page']); + for ($i = 0; $i < 11; $i++) { + $this->drupalCreateNode(); + } + + // Test Full Pager. + $this->getPreviewAJAX('test_pager_full_ajax', 'default', 5); + + // Test that the pager is present and rendered. + $elements = $this->xpath('//ul[contains(@class, :class)]/li', [':class' => 'pager__items']); + $this->assertTrue(!empty($elements), 'Full pager found.'); + + // Verify elements and links to pages. + // We expect to find 5 elements: current page == 1, links to pages 2 and + // and 3, links to 'next >' and 'last >>' pages. + $this->assertClass($elements[0], 'is-active', 'Element for current page has .is-active class.'); + $this->assertTrue($elements[0]->find('css', 'a'), 'Element for current page has link.'); + + $this->assertClass($elements[1], 'pager__item', 'Element for page 2 has .pager__item class.'); + $this->assertTrue($elements[1]->find('css', 'a'), 'Link to page 2 found.'); + + $this->assertClass($elements[2], 'pager__item', 'Element for page 3 has .pager__item class.'); + $this->assertTrue($elements[2]->find('css', 'a'), 'Link to page 3 found.'); + + $this->assertClass($elements[3], 'pager__item--next', 'Element for next page has .pager__item--next class.'); + $this->assertTrue($elements[3]->find('css', 'a'), 'Link to next page found.'); + + $this->assertClass($elements[4], 'pager__item--last', 'Element for last page has .pager__item--last class.'); + $this->assertTrue($elements[4]->find('css', 'a'), 'Link to last page found.'); + + // Navigate to next page. + $elements = $this->xpath('//li[contains(@class, :class)]/a', [':class' => 'pager__item--next']); + $this->clickPreviewLinkAJAX($elements[0], 5); + + // Test that the pager is present and rendered. + $elements = $this->xpath('//ul[contains(@class, :class)]/li', [':class' => 'pager__items']); + $this->assertTrue(!empty($elements), 'Full pager found.'); + + // Verify elements and links to pages. + // We expect to find 7 elements: links to '<< first' and '< previous' + // pages, link to page 1, current page == 2, link to page 3 and links + // to 'next >' and 'last >>' pages. + $this->assertClass($elements[0], 'pager__item--first', 'Element for first page has .pager__item--first class.'); + $this->assertTrue($elements[0]->find('css', 'a'), 'Link to first page found.'); + + $this->assertClass($elements[1], 'pager__item--previous', 'Element for previous page has .pager__item--previous class.'); + $this->assertTrue($elements[1]->find('css', 'a'), 'Link to previous page found.'); + + $this->assertClass($elements[2], 'pager__item', 'Element for page 1 has .pager__item class.'); + $this->assertTrue($elements[2]->find('css', 'a'), 'Link to page 1 found.'); + + $this->assertClass($elements[3], 'is-active', 'Element for current page has .is-active class.'); + $this->assertTrue($elements[3]->find('css', 'a'), 'Element for current page has link.'); + + $this->assertClass($elements[4], 'pager__item', 'Element for page 3 has .pager__item class.'); + $this->assertTrue($elements[4]->find('css', 'a'), 'Link to page 3 found.'); + + $this->assertClass($elements[5], 'pager__item--next', 'Element for next page has .pager__item--next class.'); + $this->assertTrue($elements[5]->find('css', 'a'), 'Link to next page found.'); + + $this->assertClass($elements[6], 'pager__item--last', 'Element for last page has .pager__item--last class.'); + $this->assertTrue($elements[6]->find('css', 'a'), 'Link to last page found.'); + + // Test Mini Pager. + $this->getPreviewAJAX('test_mini_pager_ajax', 'default', 3); + + // Test that the pager is present and rendered. + $elements = $this->xpath('//ul[contains(@class, :class)]/li', [':class' => 'pager__items']); + $this->assertTrue(!empty($elements), 'Mini pager found.'); + + // Verify elements and links to pages. + // We expect to find current pages element with no link, next page element + // with a link, and not to find previous page element. + $this->assertClass($elements[0], 'is-active', 'Element for current page has .is-active class.'); + + $this->assertClass($elements[1], 'pager__item--next', 'Element for next page has .pager__item--next class.'); + $this->assertTrue($elements[1]->find('css', 'a'), 'Link to next page found.'); + + // Navigate to next page. + $elements = $this->xpath('//li[contains(@class, :class)]/a', [':class' => 'pager__item--next']); + $this->clickPreviewLinkAJAX($elements[0], 3); + + // Test that the pager is present and rendered. + $elements = $this->xpath('//ul[contains(@class, :class)]/li', [':class' => 'pager__items']); + $this->assertTrue(!empty($elements), 'Mini pager found.'); + + // Verify elements and links to pages. + // We expect to find 3 elements: previous page with a link, current + // page with no link, and next page with a link. + $this->assertClass($elements[0], 'pager__item--previous', 'Element for previous page has .pager__item--previous class.'); + $this->assertTrue($elements[0]->find('css', 'a'), 'Link to previous page found.'); + + $this->assertClass($elements[1], 'is-active', 'Element for current page has .is-active class.'); + $this->assertEmpty($elements[1]->find('css', 'a'), 'Element for current page has no link.'); + + $this->assertClass($elements[2], 'pager__item--next', 'Element for next page has .pager__item--next class.'); + $this->assertTrue($elements[2]->find('css', 'a'), 'Link to next page found.'); + } + + /** + * Tests the link to sort in the preview form. + */ + public function testPreviewSortLink() { + // Get the preview. + $this->getPreviewAJAX('test_click_sort_ajax', 'page_1', 0); + + // Test that the header label is present. + $elements = $this->xpath('//th[contains(@class, :class)]/a', [':class' => 'views-field views-field-name']); + $this->assertTrue(!empty($elements), 'The header label is present.'); + + // Verify link. + $this->assertLinkByHref('preview/page_1?_wrapper_format=drupal_ajax&order=name&sort=desc', 0, 'The output URL is as expected.'); + + // Click link to sort. + $elements[0]->click(); + $sort_link = $this->assertSession()->waitForElement('xpath', '//th[contains(@class, \'views-field views-field-name is-active\')]/a'); + + $this->assertNotEmpty($sort_link); + + // Verify link. + $this->assertLinkByHref('preview/page_1?_wrapper_format=drupal_ajax&order=name&sort=asc', 0, 'The output URL is as expected.'); + } + + /** + * Get the preview form and force an AJAX preview update. + * + * @param string $view_name + * The view to test. + * @param string $panel_id + * The view panel to test. + * @param int $row_count + * The expected number of rows in the preview. + */ + protected function getPreviewAJAX($view_name, $panel_id, $row_count) { + $this->drupalGet('admin/structure/views/view/' . $view_name . '/edit/' . $panel_id); + $this->getSession()->getPage()->pressButton('Update preview'); + $this->assertSession()->assertWaitOnAjaxRequest(); + $this->assertPreviewAJAX($row_count); + } + + /** + * Click on a preview link. + * + * @param \Behat\Mink\Element\NodeElement $element + * The element to click. + * @param int $row_count + * The expected number of rows in the preview. + */ + protected function clickPreviewLinkAJAX(NodeElement $element, $row_count) { + $element->click(); + $this->assertSession()->assertWaitOnAjaxRequest(); + $this->assertPreviewAJAX($row_count); + } + + /** + * Assert that the preview contains expected data. + * + * @param int $row_count + * The expected number of rows in the preview. + */ + protected function assertPreviewAJAX($row_count) { + $elements = $this->getSession()->getPage()->findAll('css', '.view-content .views-row'); + $this->assertCount($row_count, $elements, 'Expected items found on page.'); + } + + /** + * Asserts that an element has a given class. + * + * @param \Behat\Mink\Element\NodeElement $element + * The element to test. + * @param string $class + * The class to assert. + * @param string $message + * (optional) A verbose message to output. + */ + protected function assertClass(NodeElement $element, $class, $message = NULL) { + if (!isset($message)) { + $message = "Class .$class found."; + } + $this->assertTrue(strpos($element->getAttribute('class'), $class) !== FALSE, $message); + } + +} diff --git a/web/core/modules/workspaces/src/EntityTypeInfo.php b/web/core/modules/workspaces/src/EntityTypeInfo.php index 3b91f84e5..5495c7fa4 100644 --- a/web/core/modules/workspaces/src/EntityTypeInfo.php +++ b/web/core/modules/workspaces/src/EntityTypeInfo.php @@ -70,4 +70,18 @@ class EntityTypeInfo implements ContainerInjectionInterface { } } + /** + * Alters field plugin definitions. + * + * @param array[] $definitions + * An array of field plugin definitions. + * + * @see hook_field_info_alter() + */ + public function fieldInfoAlter(&$definitions) { + if (isset($definitions['entity_reference'])) { + $definitions['entity_reference']['constraints']['EntityReferenceSupportedNewEntities'] = []; + } + } + } diff --git a/web/core/modules/workspaces/src/Plugin/Validation/Constraint/EntityReferenceSupportedNewEntitiesConstraint.php b/web/core/modules/workspaces/src/Plugin/Validation/Constraint/EntityReferenceSupportedNewEntitiesConstraint.php new file mode 100644 index 000000000..894e34374 --- /dev/null +++ b/web/core/modules/workspaces/src/Plugin/Validation/Constraint/EntityReferenceSupportedNewEntitiesConstraint.php @@ -0,0 +1,24 @@ +workspaceManager = $workspaceManager; + $this->entityTypeManager = $entityTypeManager; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('workspaces.manager'), + $container->get('entity_type.manager') + ); + } + + /** + * {@inheritdoc} + */ + public function validate($value, Constraint $constraint) { + if ($this->workspaceManager->getActiveWorkspace()->isDefaultWorkspace()) { + return; + } + + $target_entity_type_id = $value->getFieldDefinition()->getFieldStorageDefinition()->getSetting('target_type'); + $target_entity_type = $this->entityTypeManager->getDefinition($target_entity_type_id); + + if ($value->hasNewEntity() && !$this->workspaceManager->isEntityTypeSupported($target_entity_type)) { + $this->context->addViolation($constraint->message, ['%collection_label' => $target_entity_type->getCollectionLabel()]); + } + } + +} diff --git a/web/core/modules/workspaces/src/WorkspaceManager.php b/web/core/modules/workspaces/src/WorkspaceManager.php index faa5f8ffa..837edefe8 100644 --- a/web/core/modules/workspaces/src/WorkspaceManager.php +++ b/web/core/modules/workspaces/src/WorkspaceManager.php @@ -2,6 +2,7 @@ namespace Drupal\workspaces; +use Drupal\Core\Cache\MemoryCache\MemoryCacheInterface; use Drupal\Core\DependencyInjection\ClassResolverInterface; use Drupal\Core\Entity\EntityPublishedInterface; use Drupal\Core\Entity\EntityTypeInterface; @@ -47,6 +48,13 @@ class WorkspaceManager implements WorkspaceManagerInterface { */ protected $entityTypeManager; + /** + * The entity memory cache service. + * + * @var \Drupal\Core\Cache\MemoryCache\MemoryCacheInterface + */ + protected $entityMemoryCache; + /** * The current user. * @@ -96,6 +104,8 @@ class WorkspaceManager implements WorkspaceManagerInterface { * The request stack. * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager * The entity type manager. + * @param \Drupal\Core\Cache\MemoryCache\MemoryCacheInterface $entity_memory_cache + * The entity memory cache service. * @param \Drupal\Core\Session\AccountProxyInterface $current_user * The current user. * @param \Drupal\Core\State\StateInterface $state @@ -107,9 +117,10 @@ class WorkspaceManager implements WorkspaceManagerInterface { * @param array $negotiator_ids * The workspace negotiator service IDs. */ - public function __construct(RequestStack $request_stack, EntityTypeManagerInterface $entity_type_manager, AccountProxyInterface $current_user, StateInterface $state, LoggerInterface $logger, ClassResolverInterface $class_resolver, array $negotiator_ids) { + public function __construct(RequestStack $request_stack, EntityTypeManagerInterface $entity_type_manager, MemoryCacheInterface $entity_memory_cache, AccountProxyInterface $current_user, StateInterface $state, LoggerInterface $logger, ClassResolverInterface $class_resolver, array $negotiator_ids) { $this->requestStack = $request_stack; $this->entityTypeManager = $entity_type_manager; + $this->entityMemoryCache = $entity_memory_cache; $this->currentUser = $current_user; $this->state = $state; $this->logger = $logger; @@ -167,6 +178,31 @@ class WorkspaceManager implements WorkspaceManagerInterface { * {@inheritdoc} */ public function setActiveWorkspace(WorkspaceInterface $workspace) { + $this->doSwitchWorkspace($workspace); + + // Set the workspace on the proper negotiator. + $request = $this->requestStack->getCurrentRequest(); + foreach ($this->negotiatorIds as $negotiator_id) { + $negotiator = $this->classResolver->getInstanceFromDefinition($negotiator_id); + if ($negotiator->applies($request)) { + $negotiator->setActiveWorkspace($workspace); + break; + } + } + + return $this; + } + + /** + * Switches the current workspace. + * + * @param \Drupal\workspaces\WorkspaceInterface $workspace + * The workspace to set as active. + * + * @throws \Drupal\workspaces\WorkspaceAccessException + * Thrown when the current user doesn't have access to view the workspace. + */ + protected function doSwitchWorkspace(WorkspaceInterface $workspace) { // If the current user doesn't have access to view the workspace, they // shouldn't be allowed to switch to it. if (!$workspace->access('view') && !$workspace->isDefaultWorkspace()) { @@ -179,22 +215,30 @@ class WorkspaceManager implements WorkspaceManagerInterface { $this->activeWorkspace = $workspace; - // Set the workspace on the proper negotiator. - $request = $this->requestStack->getCurrentRequest(); - foreach ($this->negotiatorIds as $negotiator_id) { - $negotiator = $this->classResolver->getInstanceFromDefinition($negotiator_id); - if ($negotiator->applies($request)) { - $negotiator->setActiveWorkspace($workspace); - break; - } - } + // Clear the static entity cache for the supported entity types. + $cache_tags_to_invalidate = array_map(function ($entity_type_id) { + return 'entity.memory_cache:' . $entity_type_id; + }, array_keys($this->getSupportedEntityTypes())); + $this->entityMemoryCache->invalidateTags($cache_tags_to_invalidate); + } - $supported_entity_types = $this->getSupportedEntityTypes(); - foreach ($supported_entity_types as $supported_entity_type) { - $this->entityTypeManager->getStorage($supported_entity_type->id())->resetCache(); + /** + * {@inheritdoc} + */ + public function executeInWorkspace($workspace_id, callable $function) { + /** @var \Drupal\workspaces\WorkspaceInterface $workspace */ + $workspace = $this->entityTypeManager->getStorage('workspace')->load($workspace_id); + + if (!$workspace) { + throw new \InvalidArgumentException('The ' . $workspace_id . ' workspace does not exist.'); } - return $this; + $previous_active_workspace = $this->getActiveWorkspace(); + $this->doSwitchWorkspace($workspace); + $result = $function(); + $this->doSwitchWorkspace($previous_active_workspace); + + return $result; } /** diff --git a/web/core/modules/workspaces/src/WorkspaceManagerInterface.php b/web/core/modules/workspaces/src/WorkspaceManagerInterface.php index 9ce720b3f..006a2bd86 100644 --- a/web/core/modules/workspaces/src/WorkspaceManagerInterface.php +++ b/web/core/modules/workspaces/src/WorkspaceManagerInterface.php @@ -49,6 +49,19 @@ interface WorkspaceManagerInterface { */ public function setActiveWorkspace(WorkspaceInterface $workspace); + /** + * Executes the given callback function in the context of a workspace. + * + * @param string $workspace_id + * The ID of a workspace. + * @param callable $function + * The callback to be executed. + * + * @return mixed + * The callable's return value. + */ + public function executeInWorkspace($workspace_id, callable $function); + /** * Determines whether runtime entity operations should be altered. * diff --git a/web/core/modules/workspaces/tests/src/Functional/WorkspaceTest.php b/web/core/modules/workspaces/tests/src/Functional/WorkspaceTest.php index 5c7bea6e6..00276ce0b 100644 --- a/web/core/modules/workspaces/tests/src/Functional/WorkspaceTest.php +++ b/web/core/modules/workspaces/tests/src/Functional/WorkspaceTest.php @@ -16,7 +16,7 @@ class WorkspaceTest extends BrowserTestBase { /** * {@inheritdoc} */ - public static $modules = ['workspaces']; + public static $modules = ['workspaces', 'toolbar']; /** * A test user. @@ -43,6 +43,8 @@ class WorkspaceTest extends BrowserTestBase { 'create workspace', 'edit own workspace', 'edit any workspace', + 'view own workspace', + 'access toolbar', ]; $this->editor1 = $this->drupalCreateUser($permissions); @@ -69,6 +71,36 @@ class WorkspaceTest extends BrowserTestBase { $page->hasContent("This value is not valid"); } + /** + * Test that the toolbar correctly shows the active workspace. + */ + public function testWorkspaceToolbar() { + $this->drupalLogin($this->editor1); + + $this->drupalPostForm('/admin/config/workflow/workspaces/add', [ + 'id' => 'test_workspace', + 'label' => 'Test workspace', + ], 'Save'); + + // Activate the test workspace. + $this->drupalPostForm('/admin/config/workflow/workspaces/manage/test_workspace/activate', [], 'Confirm'); + + $this->drupalGet(''); + $page = $this->getSession()->getPage(); + // Toolbar should show the correct label. + $this->assertTrue($page->hasLink('Test workspace')); + + // Change the workspace label. + $this->drupalPostForm('/admin/config/workflow/workspaces/manage/test_workspace/edit', [ + 'label' => 'New name', + ], 'Save'); + + $this->drupalGet(''); + $page = $this->getSession()->getPage(); + // Toolbar should show the new label. + $this->assertTrue($page->hasLink('New name')); + } + /** * Test changing the owner of a workspace. */ diff --git a/web/core/modules/workspaces/tests/src/Kernel/EntityReferenceSupportedNewEntitiesConstraintValidatorTest.php b/web/core/modules/workspaces/tests/src/Kernel/EntityReferenceSupportedNewEntitiesConstraintValidatorTest.php new file mode 100644 index 000000000..5cef79df9 --- /dev/null +++ b/web/core/modules/workspaces/tests/src/Kernel/EntityReferenceSupportedNewEntitiesConstraintValidatorTest.php @@ -0,0 +1,81 @@ +installEntitySchema('user'); + $this->installSchema('system', ['sequences']); + $this->createUser(); + + $fields['supported_reference'] = BaseFieldDefinition::create('entity_reference')->setSetting('target_type', 'entity_test_mulrevpub'); + $fields['unsupported_reference'] = BaseFieldDefinition::create('entity_reference')->setSetting('target_type', 'entity_test'); + $this->container->get('state')->set('entity_test_mulrevpub.additional_base_field_definitions', $fields); + + $this->installEntitySchema('entity_test_mulrevpub'); + $this->initializeWorkspacesModule(); + } + + /** + * @covers ::validate + */ + public function testNewEntitiesAllowedInDefaultWorkspace() { + $entity = EntityTestMulRevPub::create([ + 'unsupported_reference' => [ + 'entity' => EntityTest::create([]), + ], + 'supported_reference' => [ + 'entity' => EntityTest::create([]), + ], + ]); + $this->assertCount(0, $entity->validate()); + } + + /** + * @covers ::validate + */ + public function testNewEntitiesForbiddenInNonDefaultWorkspace() { + $this->switchToWorkspace('stage'); + $entity = EntityTestMulRevPub::create([ + 'unsupported_reference' => [ + 'entity' => EntityTest::create([]), + ], + 'supported_reference' => [ + 'entity' => EntityTestMulRevPub::create([]), + ], + ]); + $violations = $entity->validate(); + $this->assertCount(1, $violations); + $this->assertEquals('Test entity entities can only be created in the default workspace.', $violations[0]->getMessage()); + } + +} diff --git a/web/core/modules/workspaces/tests/src/Kernel/WorkspaceIntegrationTest.php b/web/core/modules/workspaces/tests/src/Kernel/WorkspaceIntegrationTest.php index 9d77cdf00..39c365b94 100644 --- a/web/core/modules/workspaces/tests/src/Kernel/WorkspaceIntegrationTest.php +++ b/web/core/modules/workspaces/tests/src/Kernel/WorkspaceIntegrationTest.php @@ -14,7 +14,6 @@ use Drupal\Tests\node\Traits\NodeCreationTrait; use Drupal\Tests\user\Traits\UserCreationTrait; use Drupal\views\Tests\ViewResultAssertionTrait; use Drupal\views\Views; -use Drupal\workspaces\Entity\Workspace; /** * Tests a complete deployment scenario across different workspaces. @@ -28,6 +27,7 @@ class WorkspaceIntegrationTest extends KernelTestBase { use NodeCreationTrait; use UserCreationTrait; use ViewResultAssertionTrait; + use WorkspaceTestTrait; /** * The entity type manager. @@ -36,13 +36,6 @@ class WorkspaceIntegrationTest extends KernelTestBase { */ protected $entityTypeManager; - /** - * An array of test workspaces, keyed by workspace ID. - * - * @var \Drupal\workspaces\WorkspaceInterface[] - */ - protected $workspaces = []; - /** * Creation timestamp that should be incremented for each new entity. * @@ -93,34 +86,6 @@ class WorkspaceIntegrationTest extends KernelTestBase { $this->createNode(['title' => 'live - 2 - r2 - unpublished', 'created' => $this->createdTimestamp++, 'status' => FALSE]); } - /** - * Enables the Workspaces module and creates two workspaces. - */ - protected function initializeWorkspacesModule() { - // Enable the Workspaces module here instead of the static::$modules array - // so we can test it with default content. - $this->enableModules(['workspaces']); - $this->container = \Drupal::getContainer(); - $this->entityTypeManager = \Drupal::entityTypeManager(); - - $this->installEntitySchema('workspace'); - $this->installEntitySchema('workspace_association'); - - // Create two workspaces by default, 'live' and 'stage'. - $this->workspaces['live'] = Workspace::create(['id' => 'live']); - $this->workspaces['live']->save(); - $this->workspaces['stage'] = Workspace::create(['id' => 'stage']); - $this->workspaces['stage']->save(); - - $permissions = [ - 'administer nodes', - 'create workspace', - 'edit any workspace', - 'view any workspace', - ]; - $this->setCurrentUser($this->createUser($permissions)); - } - /** * Tests various scenarios for creating and deploying content in workspaces. */ @@ -492,6 +457,57 @@ class WorkspaceIntegrationTest extends KernelTestBase { $entity_test->delete(); } + /** + * @covers \Drupal\workspaces\WorkspaceManager::executeInWorkspace + */ + public function testExecuteInWorkspaceContext() { + $this->initializeWorkspacesModule(); + + // Create an entity in the default workspace. + $this->switchToWorkspace('live'); + $node = $this->createNode([ + 'title' => 'live node 1', + ]); + $node->save(); + + // Switch to the 'stage' workspace and change some values for the referenced + // entities. + $this->switchToWorkspace('stage'); + $node->title->value = 'stage node 1'; + $node->save(); + + // Switch back to the default workspace and run the baseline assertions. + $this->switchToWorkspace('live'); + $storage = $this->entityTypeManager->getStorage('node'); + + $this->assertEquals('live', $this->workspaceManager->getActiveWorkspace()->id()); + + $live_node = $storage->loadUnchanged($node->id()); + $this->assertEquals('live node 1', $live_node->title->value); + + $result = $storage->getQuery() + ->condition('title', 'live node 1') + ->execute(); + $this->assertEquals([$live_node->getRevisionId() => $node->id()], $result); + + // Try the same assertions in the context of the 'stage' workspace. + $this->workspaceManager->executeInWorkspace('stage', function () use ($node, $storage) { + $this->assertEquals('stage', $this->workspaceManager->getActiveWorkspace()->id()); + + $stage_node = $storage->loadUnchanged($node->id()); + $this->assertEquals('stage node 1', $stage_node->title->value); + + $result = $storage->getQuery() + ->condition('title', 'stage node 1') + ->execute(); + $this->assertEquals([$stage_node->getRevisionId() => $stage_node->id()], $result); + }); + + // Check that the 'stage' workspace was not persisted by the workspace + // manager. + $this->assertEquals('live', $this->workspaceManager->getActiveWorkspace()->id()); + } + /** * Checks entity load, entity queries and views results for a test scenario. * @@ -681,18 +697,6 @@ class WorkspaceIntegrationTest extends KernelTestBase { } } - /** - * Sets a given workspace as active. - * - * @param string $workspace_id - * The ID of the workspace to switch to. - */ - protected function switchToWorkspace($workspace_id) { - // Switch the test runner's context to the specified workspace. - $workspace = $this->entityTypeManager->getStorage('workspace')->load($workspace_id); - \Drupal::service('workspaces.manager')->setActiveWorkspace($workspace); - } - /** * Flattens the expectations array defined by testWorkspaces(). * diff --git a/web/core/modules/workspaces/tests/src/Kernel/WorkspaceTestTrait.php b/web/core/modules/workspaces/tests/src/Kernel/WorkspaceTestTrait.php new file mode 100644 index 000000000..13cff6a3d --- /dev/null +++ b/web/core/modules/workspaces/tests/src/Kernel/WorkspaceTestTrait.php @@ -0,0 +1,67 @@ +enableModules(['workspaces']); + $this->container = \Drupal::getContainer(); + $this->entityTypeManager = \Drupal::entityTypeManager(); + $this->workspaceManager = \Drupal::service('workspaces.manager'); + + $this->installEntitySchema('workspace'); + $this->installEntitySchema('workspace_association'); + + // Create two workspaces by default, 'live' and 'stage'. + $this->workspaces['live'] = Workspace::create(['id' => 'live']); + $this->workspaces['live']->save(); + $this->workspaces['stage'] = Workspace::create(['id' => 'stage']); + $this->workspaces['stage']->save(); + + $permissions = array_intersect([ + 'administer nodes', + 'create workspace', + 'edit any workspace', + 'view any workspace', + ], array_keys($this->container->get('user.permissions')->getPermissions())); + $this->setCurrentUser($this->createUser($permissions)); + } + + /** + * Sets a given workspace as active. + * + * @param string $workspace_id + * The ID of the workspace to switch to. + */ + protected function switchToWorkspace($workspace_id) { + // Switch the test runner's context to the specified workspace. + $workspace = $this->entityTypeManager->getStorage('workspace')->load($workspace_id); + \Drupal::service('workspaces.manager')->setActiveWorkspace($workspace); + } + +} diff --git a/web/core/modules/workspaces/workspaces.module b/web/core/modules/workspaces/workspaces.module index f279c4389..8f4f2fa2e 100644 --- a/web/core/modules/workspaces/workspaces.module +++ b/web/core/modules/workspaces/workspaces.module @@ -56,6 +56,15 @@ function workspaces_form_alter(&$form, FormStateInterface $form_state, $form_id) ->formAlter($form, $form_state, $form_id); } +/** + * Implements hook_field_info_alter(). + */ +function workspaces_field_info_alter(&$definitions) { + \Drupal::service('class_resolver') + ->getInstanceFromDefinition(EntityTypeInfo::class) + ->fieldInfoAlter($definitions); +} + /** * Implements hook_entity_load(). */ @@ -154,15 +163,15 @@ function workspaces_toolbar() { $current_user = \Drupal::currentUser(); if (!$current_user->hasPermission('administer workspaces') - || !$current_user->hasPermission('view own workspace') - || !$current_user->hasPermission('view any workspace')) { + && !$current_user->hasPermission('view own workspace') + && !$current_user->hasPermission('view any workspace')) { return $items; } /** @var \Drupal\workspaces\WorkspaceInterface $active_workspace */ $active_workspace = \Drupal::service('workspaces.manager')->getActiveWorkspace(); - $items['workspace'] = [ + $items['workspace'] += [ '#type' => 'toolbar_item', 'tab' => [ '#type' => 'link', @@ -180,6 +189,7 @@ function workspaces_toolbar() { ], ]), ], + '#cache' => ['tags' => $active_workspace->getCacheTags()], ], '#wrapper_attributes' => [ 'class' => ['workspaces-toolbar-tab'], diff --git a/web/core/modules/workspaces/workspaces.services.yml b/web/core/modules/workspaces/workspaces.services.yml index a238473d1..ab9e8bf89 100644 --- a/web/core/modules/workspaces/workspaces.services.yml +++ b/web/core/modules/workspaces/workspaces.services.yml @@ -1,7 +1,7 @@ services: workspaces.manager: class: Drupal\workspaces\WorkspaceManager - arguments: ['@request_stack', '@entity_type.manager', '@current_user', '@state', '@logger.channel.workspaces', '@class_resolver'] + arguments: ['@request_stack', '@entity_type.manager', '@entity.memory_cache', '@current_user', '@state', '@logger.channel.workspaces', '@class_resolver'] tags: - { name: service_id_collector, tag: workspace_negotiator } workspaces.operation_factory: diff --git a/web/core/profiles/demo_umami/config/install/views.view.articles_aside.yml b/web/core/profiles/demo_umami/config/install/views.view.articles_aside.yml index 8a4357f84..d65688686 100644 --- a/web/core/profiles/demo_umami/config/install/views.view.articles_aside.yml +++ b/web/core/profiles/demo_umami/config/install/views.view.articles_aside.yml @@ -144,6 +144,20 @@ display: expose: label: '' granularity: second + nid: + id: nid + table: node_field_data + field: nid + relationship: none + group_type: group + admin_label: '' + order: ASC + exposed: false + expose: + label: '' + entity_type: node + entity_field: nid + plugin_id: standard title: 'More featured articles' header: { } footer: { } diff --git a/web/core/profiles/demo_umami/config/install/views.view.featured_articles.yml b/web/core/profiles/demo_umami/config/install/views.view.featured_articles.yml index 3ddc61bb7..9ce6c4cab 100644 --- a/web/core/profiles/demo_umami/config/install/views.view.featured_articles.yml +++ b/web/core/profiles/demo_umami/config/install/views.view.featured_articles.yml @@ -158,6 +158,20 @@ display: expose: label: '' granularity: second + nid: + id: nid + table: node_field_data + field: nid + relationship: none + group_type: group + admin_label: '' + order: ASC + exposed: false + expose: + label: '' + entity_type: node + entity_field: nid + plugin_id: standard title: Articles header: { } footer: { } diff --git a/web/core/profiles/demo_umami/config/install/views.view.frontpage.yml b/web/core/profiles/demo_umami/config/install/views.view.frontpage.yml index 7f4f0db3f..a97299283 100644 --- a/web/core/profiles/demo_umami/config/install/views.view.frontpage.yml +++ b/web/core/profiles/demo_umami/config/install/views.view.frontpage.yml @@ -249,6 +249,20 @@ display: granularity: second entity_type: node entity_field: created + nid: + id: nid + table: node_field_data + field: nid + relationship: none + group_type: group + admin_label: '' + order: ASC + exposed: false + expose: + label: '' + entity_type: node + entity_field: nid + plugin_id: standard style: type: default options: diff --git a/web/core/profiles/demo_umami/config/install/views.view.recipes.yml b/web/core/profiles/demo_umami/config/install/views.view.recipes.yml index e7cf9fd6f..7e273c970 100644 --- a/web/core/profiles/demo_umami/config/install/views.view.recipes.yml +++ b/web/core/profiles/demo_umami/config/install/views.view.recipes.yml @@ -158,6 +158,20 @@ display: expose: label: '' granularity: second + nid: + id: nid + table: node_field_data + field: nid + relationship: none + group_type: group + admin_label: '' + order: ASC + exposed: false + expose: + label: '' + entity_type: node + entity_field: nid + plugin_id: standard title: Recipes header: { } footer: { } diff --git a/web/core/profiles/demo_umami/themes/umami/css/components/blocks/footer-promo/footer-promo.css b/web/core/profiles/demo_umami/themes/umami/css/components/blocks/footer-promo/footer-promo.css index 8476f1a6a..7486d488f 100644 --- a/web/core/profiles/demo_umami/themes/umami/css/components/blocks/footer-promo/footer-promo.css +++ b/web/core/profiles/demo_umami/themes/umami/css/components/blocks/footer-promo/footer-promo.css @@ -36,6 +36,9 @@ background: url('../../../../images/svg/pointer--white.svg') no-repeat center center; vertical-align: middle; } +[dir=rtl] .block-type-footer-promo-block .footer-promo-content a:after { + transform: rotate(180deg); +} @media screen and (min-width: 60rem) { .block-type-footer-promo-block { diff --git a/web/core/profiles/demo_umami/themes/umami/css/components/blocks/search/search-results.css b/web/core/profiles/demo_umami/themes/umami/css/components/blocks/search/search-results.css index 06c196381..b140da4ad 100644 --- a/web/core/profiles/demo_umami/themes/umami/css/components/blocks/search/search-results.css +++ b/web/core/profiles/demo_umami/themes/umami/css/components/blocks/search/search-results.css @@ -19,6 +19,7 @@ .search-form #edit-basic { display: flex; align-items: flex-end; + flex: 1 1; } .search-form .form-type-search { margin: 0; @@ -43,6 +44,7 @@ .search-form .search-help-link { padding: 1.28rem; margin: 0 1rem 1rem 0; + flex: 1 1; } .search-form #edit-advanced { @@ -96,7 +98,11 @@ background: #fff; border: 1px solid #fcece7; padding: 1.28rem; - margin: 0 0 1rem 0; + margin: 0 0 1rem 0; /* LTR */ +} +/* Apply right margin to keep aligned with title and exposed filter. */ +[dir=rtl] .search-results li { + margin: 0 1rem 1rem 0; } .search-results .search-result__snippet { diff --git a/web/core/profiles/demo_umami/themes/umami/css/components/blocks/search/search.css b/web/core/profiles/demo_umami/themes/umami/css/components/blocks/search/search.css index fe3684298..176a09c44 100644 --- a/web/core/profiles/demo_umami/themes/umami/css/components/blocks/search/search.css +++ b/web/core/profiles/demo_umami/themes/umami/css/components/blocks/search/search.css @@ -4,7 +4,7 @@ */ .search-form + h2 { - margin: 0 14px 1rem; + margin: 0 1rem 1rem; } .search-block-form { @@ -67,22 +67,32 @@ width: 20em; max-width: calc(100vw - 6.25em); height: auto; - margin: 0 -2px 0 0; + margin: 0 -2px 0 0; /* LTR */ padding: 7px 8px 7px 32px; color: #464646; - border: 1px solid #dbdbdb; - border-right: none; + border: 1px solid #dbdbdb; /* LTR */ + border-right: none; /* LTR */ border-radius: 3px; background: url(../../../../images/svg/search.svg) no-repeat 0.5em center #fff; font-size: 0.875rem; line-height: normal; } +[dir=rtl] .form-search { + margin: 0 0 0 -2px; + border-left: none; + border-right: 1px solid #dbdbdb; +} .form-search:focus { - margin: 0 0 -2px -2px; + margin: 0 0 -2px -2px; /* LTR */ padding: 5px 8px 5px 32px; outline: none; } +[dir=rtl] .form-search:focus { + margin: 0 -2px -2px 0; + background-position: 0.35em; + border: 3px solid #00836d; +} .form-search::placeholder { opacity: 1; @@ -101,8 +111,16 @@ .search-block-form .form-submit, .search-form .form-submit { /* Take off the border radius on the left side as it bumps into the search field */ - border-top-left-radius: 0; - border-bottom-left-radius: 0; + border-top-left-radius: 0; /* LTR */ + border-bottom-left-radius: 0; /* LTR */ +} +[dir=rtl] .search-block-form .form-submit, +[dir=rtl] .search-form .form-submit { + /* Take off the border radius on the left side as it bumps into the search field */ + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; + border-top-right-radius: 0; + border-bottom-right-radius: 0; } .search-block-form .form-submit:focus, @@ -110,6 +128,14 @@ .search-form .form-submit:focus, .search-form .form-submit:hover { margin: 0; - border-top-left-radius: 4px; - border-bottom-left-radius: 4px; + border-top-left-radius: 4px; /* LTR */ + border-bottom-left-radius: 4px; /* LTR */ +} +/* Apply border radius to all corners to override LTR and RTL (normal state) changes. */ +[dir=rtl] .search-block-form .form-submit:focus, +[dir=rtl] .search-block-form .form-submit:hover, +[dir=rtl] .search-form .form-submit:focus, +[dir=rtl] .search-form .form-submit:hover { + margin: 0; + border-radius: 4px; } diff --git a/web/core/profiles/demo_umami/themes/umami/css/components/content-types/recipe/recipe.css b/web/core/profiles/demo_umami/themes/umami/css/components/content-types/recipe/recipe.css index 453d8b24f..ac1aff731 100644 --- a/web/core/profiles/demo_umami/themes/umami/css/components/content-types/recipe/recipe.css +++ b/web/core/profiles/demo_umami/themes/umami/css/components/content-types/recipe/recipe.css @@ -36,11 +36,19 @@ align-items: center; min-height: 40px; margin-bottom: 0.96em; - padding-left: 48px; + padding-left: 48px; /* LTR */ background-repeat: no-repeat; - background-position: left center; + background-position: left center; /* LTR */ background-size: 40px 40px; } +[dir=rtl] .node--type-recipe.node--view-mode-full .field--name-field-preparation-time, +[dir=rtl] .node--type-recipe.node--view-mode-full .field--name-field-cooking-time, +[dir=rtl] .node--type-recipe.node--view-mode-full .field--name-field-number-of-servings, +[dir=rtl] .node--type-recipe.node--view-mode-full .field--name-field-difficulty { + background-position: right center; + padding-left: 0; + padding-right: 48px; +} .node--type-recipe.node--view-mode-full .field--name-field-preparation-time { background-image: url(../../../../images/svg/knife.svg); } diff --git a/web/core/profiles/demo_umami/themes/umami/css/components/fields/recipe-instruction.css b/web/core/profiles/demo_umami/themes/umami/css/components/fields/recipe-instruction.css index af9ee7ee3..6b3797e71 100644 --- a/web/core/profiles/demo_umami/themes/umami/css/components/fields/recipe-instruction.css +++ b/web/core/profiles/demo_umami/themes/umami/css/components/fields/recipe-instruction.css @@ -28,15 +28,22 @@ .field--name-field-recipe-instruction ol > li { position: relative; min-height: 1.5em; - padding: 0 0 0.6em 2.5em; + padding: 0 0 0.6em 2.5em; /* LTR */ list-style: none; counter-increment: step-counter; } +[dir=rtl] .field--name-field-recipe-instruction ol > li { + padding: 0 2.5em 0.6em 0; +} .field--name-field-recipe-instruction ol > li::before { position: absolute; top: 0; - left: 0; + left: 0; /* LTR */ content: counter(step-counter); color: #cc2a00; font-size: 1.5rem; } +[dir=rtl] .field--name-field-recipe-instruction ol > li::before { + right: 0; + left: auto; +} diff --git a/web/core/profiles/demo_umami/themes/umami/css/components/forms/contact.css b/web/core/profiles/demo_umami/themes/umami/css/components/forms/contact.css index 5ba7810ce..b1d265f31 100644 --- a/web/core/profiles/demo_umami/themes/umami/css/components/forms/contact.css +++ b/web/core/profiles/demo_umami/themes/umami/css/components/forms/contact.css @@ -41,6 +41,10 @@ @media screen and (min-width: 30rem) { /* 480px */ .contact-form .form-actions .button { - margin-left: 1em; + margin-left: 1em; /* LTR */ + } + [dir=rtl] .contact-form .form-actions .button + .button { + margin-right: 1em; + margin-left: 0; } } diff --git a/web/core/profiles/demo_umami/themes/umami/css/components/messages/messages.css b/web/core/profiles/demo_umami/themes/umami/css/components/messages/messages.css index d7a4f0498..b49bc8e48 100644 --- a/web/core/profiles/demo_umami/themes/umami/css/components/messages/messages.css +++ b/web/core/profiles/demo_umami/themes/umami/css/components/messages/messages.css @@ -17,7 +17,10 @@ margin-top: 1.538em; } .messages__content { - background: no-repeat 0 center; + background: no-repeat 0 center; /* LTR */ +} +[dir=rtl] .messages__content { + background-position: right; } .messages--status { background-color: #e6eee0; @@ -49,7 +52,11 @@ margin: 0; } .messages__item { - margin-left: 24px; + margin-left: 24px; /* LTR */ +} +[dir=rtl] .messages__item { + margin-left: 0; + margin-right: 24px; } .messages__item + .messages__item { margin-top: 0.769em; diff --git a/web/core/profiles/demo_umami/themes/umami/css/components/navigation/breadcrumbs/breadcrumbs.css b/web/core/profiles/demo_umami/themes/umami/css/components/navigation/breadcrumbs/breadcrumbs.css index f04578f0e..6cfde97e9 100644 --- a/web/core/profiles/demo_umami/themes/umami/css/components/navigation/breadcrumbs/breadcrumbs.css +++ b/web/core/profiles/demo_umami/themes/umami/css/components/navigation/breadcrumbs/breadcrumbs.css @@ -5,6 +5,9 @@ .breadcrumb { padding: 0.79rem 1.266rem; } +.breadcrumb li { + display: inline-block; +} /* Large */ @media screen and (min-width: 60rem) { /* 960px */ .breadcrumb { diff --git a/web/core/profiles/demo_umami/themes/umami/css/components/navigation/menu-account/menu-account.css b/web/core/profiles/demo_umami/themes/umami/css/components/navigation/menu-account/menu-account.css index ac4f39185..563a902cc 100644 --- a/web/core/profiles/demo_umami/themes/umami/css/components/navigation/menu-account/menu-account.css +++ b/web/core/profiles/demo_umami/themes/umami/css/components/navigation/menu-account/menu-account.css @@ -7,7 +7,10 @@ .menu--account { display: block; flex: 0 1 50%; - text-align: right; + text-align: right; /* LTR */ + } + [dir="rtl"] .menu--account { + text-align: left; } } @@ -19,7 +22,11 @@ line-height: 1.5; } .menu-account__item + .menu-account__item { - margin-left: 1em; + margin-left: 1em; /* LTR */ +} +[dir="rtl"] .menu-account__item + .menu-account__item { + margin-left: 0; + margin-right: 1em; } .menu-account__link, .menu-account__link:hover { diff --git a/web/core/profiles/demo_umami/themes/umami/css/components/navigation/menu-main/menu-main.css b/web/core/profiles/demo_umami/themes/umami/css/components/navigation/menu-main/menu-main.css index 44b715b18..54c54d9a2 100644 --- a/web/core/profiles/demo_umami/themes/umami/css/components/navigation/menu-main/menu-main.css +++ b/web/core/profiles/demo_umami/themes/umami/css/components/navigation/menu-main/menu-main.css @@ -86,7 +86,11 @@ margin-bottom: 0; } .menu-main__item + .menu-main__item { - margin-left: 2.5em; + margin-left: 2.5em; /* LTR */ + } + [dir="rtl"] .menu-main__item + .menu-main__item { + margin-left: 0; + margin-right: 2.5em; } } diff --git a/web/core/profiles/demo_umami/themes/umami/css/components/navigation/tabs/tabs.css b/web/core/profiles/demo_umami/themes/umami/css/components/navigation/tabs/tabs.css index 79cc1194c..84e0b0971 100644 --- a/web/core/profiles/demo_umami/themes/umami/css/components/navigation/tabs/tabs.css +++ b/web/core/profiles/demo_umami/themes/umami/css/components/navigation/tabs/tabs.css @@ -13,9 +13,12 @@ padding: 0; } .tabs .tab { - margin: 0; + margin: 0; /* LTR */ background-color: #e6eee0; } +[dir=rtl] .tabs .tab { + margin: 0; +} .tabs .tab.is-active { background-color: #fff; } diff --git a/web/core/profiles/demo_umami/themes/umami/css/components/views/promoted-items.css b/web/core/profiles/demo_umami/themes/umami/css/components/views/promoted-items.css index b81adae59..0ad4107c2 100644 --- a/web/core/profiles/demo_umami/themes/umami/css/components/views/promoted-items.css +++ b/web/core/profiles/demo_umami/themes/umami/css/components/views/promoted-items.css @@ -47,9 +47,13 @@ @media screen and (min-width: 60rem) { /* 960px */ .view-promoted-items--single > .view-content { flex: 0 0 50%; - margin-right: 14px; + margin-right: 14px; /* LTR */ display: flex; } + [dir=rtl] .view-promoted-items--single > .view-content { + margin-right: 0; + margin-left: 14px; + } } .view-promoted-items--single > .view-content .views-row { @@ -72,9 +76,13 @@ /* Large */ @media screen and (min-width: 60rem) { /* 960px */ .view-promoted-items--single > .attachment-after { - margin-left: 14px; + margin-left: 14px; /* LTR */ display: flex; } + [dir=rtl] .view-promoted-items--single > .attachment-after { + margin-left: 0; + margin-right: 14px; + } } /* Large */ diff --git a/web/core/tests/Drupal/FunctionalJavascriptTests/Ajax/ElementValidationTest.php b/web/core/tests/Drupal/FunctionalJavascriptTests/Ajax/ElementValidationTest.php new file mode 100644 index 000000000..48bda56cd --- /dev/null +++ b/web/core/tests/Drupal/FunctionalJavascriptTests/Ajax/ElementValidationTest.php @@ -0,0 +1,53 @@ +drupalGet('ajax_validation_test'); + $page = $this->getSession()->getPage(); + $assert = $this->assertSession(); + + // Partially complete the form with a string. + $page->fillField('drivertext', 'some dumb text'); + // Move focus away from this field to trigger AJAX. + $page->findField('spare_required_field')->focus(); + + // When the AJAX command updates the DOM a
    unsorted list + // "message__list" structure will appear on the page echoing back the + // "some dumb text" message. + $placeholder_text = $assert->waitForElement('css', "ul.messages__list li.messages__item em:contains('some dumb text')"); + $this->assertNotNull($placeholder_text, 'A callback successfully echoed back a string.'); + + $this->drupalGet('ajax_validation_test'); + // Partialy complete the form with a number. + $page->fillField('drivernumber', '12345'); + $page->findField('spare_required_field')->focus(); + + // The AJAX request/resonse will complete successfully when a InsertCommand + // injects a message with a placeholder element into the DOM with the + // submitted number. + $placeholder_number = $assert->waitForElement('css', "ul.messages__list li.messages__item em:contains('12345')"); + $this->assertNotNull($placeholder_number, 'A callback successfully echoed back a number.'); + } + +} diff --git a/web/core/tests/Drupal/FunctionalJavascriptTests/Ajax/FormValuesTest.php b/web/core/tests/Drupal/FunctionalJavascriptTests/Ajax/FormValuesTest.php new file mode 100644 index 000000000..d1a3e5c2b --- /dev/null +++ b/web/core/tests/Drupal/FunctionalJavascriptTests/Ajax/FormValuesTest.php @@ -0,0 +1,84 @@ +drupalLogin($this->drupalCreateUser(['access content'])); + } + + /** + * Submits forms with select and checkbox elements via Ajax. + */ + public function testSimpleAjaxFormValue() { + + $this->drupalGet('ajax_forms_test_get_form'); + + $session = $this->getSession(); + $assertSession = $this->assertSession(); + + // Verify form values of a select element. + foreach (['green', 'blue', 'red'] as $item) { + // Updating the field will trigger a AJAX request/response. + $session->getPage()->selectFieldOption('select', $item); + + // The AJAX command in the response will update the DOM + $select = $assertSession->waitForElement('css', "div#ajax_selected_color:contains('$item')"); + $this->assertNotNull($select, "DataCommand has updated the page with a value of $item."); + } + + // Verify form values of a checkbox element. + $session->getPage()->checkField('checkbox'); + $div0 = $this->assertSession()->waitForElement('css', "div#ajax_checkbox_value:contains('checked')"); + $this->assertNotNull($div0, 'DataCommand updates the DOM as expected when a checkbox is selected'); + + $session->getPage()->uncheckField('checkbox'); + $div1 = $this->assertSession()->waitForElement('css', "div#ajax_checkbox_value:contains('unchecked')"); + $this->assertNotNull($div1, 'DataCommand updates the DOM as expected when a checkbox is de-selected'); + + // Verify that AJAX elements with invalid callbacks return error code 500. + // Ensure the test error log is empty before these tests. + $this->assertFalse(file_exists(DRUPAL_ROOT . '/' . $this->siteDirectory . '/error.log'), 'PHP error.log is empty.'); + // We don't need to check for the X-Drupal-Ajax-Token header with these + // invalid requests. + $this->assertAjaxHeader = FALSE; + foreach (['null', 'empty', 'nonexistent'] as $key) { + $element_name = 'select_' . $key . '_callback'; + // Updating the field will trigger a AJAX request/response. + $session->getPage()->selectFieldOption($element_name, 'green'); + + // The select element is disabled as the AJAX request is issued. + $this->assertSession()->waitForElement('css', "select[name=\"$element_name\"]:disabled"); + + // The select element is enabled as the response is receieved. + $this->assertSession()->waitForElement('css', "select[name=\"$element_name\"]:enabled"); + $this->assertTrue(file_exists(DRUPAL_ROOT . '/' . $this->siteDirectory . '/error.log'), 'PHP error.log is not empty.'); + $this->assertContains('"The specified #ajax callback is empty or not callable."', file_get_contents(DRUPAL_ROOT . '/' . $this->siteDirectory . '/error.log')); + // The exceptions are expected. Do not interpret them as a test failure. + // Not using File API; a potential error must trigger a PHP warning. + unlink(\Drupal::root() . '/' . $this->siteDirectory . '/error.log'); + } + // We need to reload the page to kill any unfinished AJAX calls before + // tearDown() is called. + $this->drupalGet('ajax_forms_test_get_form'); + } + +} diff --git a/web/core/tests/Drupal/FunctionalTests/BrowserTestBaseTest.php b/web/core/tests/Drupal/FunctionalTests/BrowserTestBaseTest.php index fb564450b..eb70f68ce 100644 --- a/web/core/tests/Drupal/FunctionalTests/BrowserTestBaseTest.php +++ b/web/core/tests/Drupal/FunctionalTests/BrowserTestBaseTest.php @@ -114,6 +114,7 @@ class BrowserTestBaseTest extends BrowserTestBase { // Test drupalPostForm(). $edit = ['bananas' => 'red']; + // Submit the form using the button label. $result = $this->drupalPostForm('form-test/object-builder', $edit, 'Save'); $this->assertSame($this->getSession()->getPage()->getContent(), $result); $value = $config_factory->get('form_test.object')->get('bananas'); @@ -123,6 +124,20 @@ class BrowserTestBaseTest extends BrowserTestBase { $value = $config_factory->get('form_test.object')->get('bananas'); $this->assertSame('', $value); + // Submit the form using the button id. + $edit = ['bananas' => 'blue']; + $result = $this->drupalPostForm('form-test/object-builder', $edit, 'edit-submit'); + $this->assertSame($this->getSession()->getPage()->getContent(), $result); + $value = $config_factory->get('form_test.object')->get('bananas'); + $this->assertSame('blue', $value); + + // Submit the form using the button name. + $edit = ['bananas' => 'purple']; + $result = $this->drupalPostForm('form-test/object-builder', $edit, 'op'); + $this->assertSame($this->getSession()->getPage()->getContent(), $result); + $value = $config_factory->get('form_test.object')->get('bananas'); + $this->assertSame('purple', $value); + // Test drupalPostForm() with no-html response. $values = Json::decode($this->drupalPostForm('form_test/form-state-values-clean', [], t('Submit'))); $this->assertTrue(1000, $values['beer']); diff --git a/web/core/tests/Drupal/FunctionalTests/Installer/InstallerDatabaseErrorMessagesTest.php b/web/core/tests/Drupal/FunctionalTests/Installer/InstallerDatabaseErrorMessagesTest.php index b6ad43767..69c209070 100644 --- a/web/core/tests/Drupal/FunctionalTests/Installer/InstallerDatabaseErrorMessagesTest.php +++ b/web/core/tests/Drupal/FunctionalTests/Installer/InstallerDatabaseErrorMessagesTest.php @@ -19,7 +19,7 @@ class InstallerDatabaseErrorMessagesTest extends InstallerTestBase { // it will try and create the drupal_install_test table as this is part of // the standard database tests performed by the installer in // Drupal\Core\Database\Install\Tasks. - Database::getConnection('default')->query('CREATE TABLE {drupal_install_test} (id int NULL)'); + Database::getConnection('default')->query('CREATE TABLE {drupal_install_test} (id int NOT NULL PRIMARY KEY)'); parent::setUpSettings(); } diff --git a/web/core/tests/Drupal/FunctionalTests/Installer/InstallerTranslationTest.php b/web/core/tests/Drupal/FunctionalTests/Installer/InstallerTranslationTest.php index ecf7d8867..5fa84aba0 100644 --- a/web/core/tests/Drupal/FunctionalTests/Installer/InstallerTranslationTest.php +++ b/web/core/tests/Drupal/FunctionalTests/Installer/InstallerTranslationTest.php @@ -48,12 +48,12 @@ class InstallerTranslationTest extends InstallerTestBase { // it will try and create the drupal_install_test table as this is part of // the standard database tests performed by the installer in // Drupal\Core\Database\Install\Tasks. - Database::getConnection('default')->query('CREATE TABLE {drupal_install_test} (id int NULL)'); + Database::getConnection('default')->query('CREATE TABLE {drupal_install_test} (id int NOT NULL PRIMARY KEY)'); parent::setUpSettings(); // Ensure that the error message translation is working. $this->assertRaw('Beheben Sie alle Probleme unten, um die Installation fortzusetzen. Informationen zur Konfiguration der Datenbankserver finden Sie in der Installationshandbuch, oder kontaktieren Sie Ihren Hosting-Anbieter.'); - $this->assertRaw('CREATE ein Test-Tabelle auf Ihrem Datenbankserver mit dem Befehl CREATE TABLE {drupal_install_test} (id int NULL) fehlgeschlagen.'); + $this->assertRaw('CREATE ein Test-Tabelle auf Ihrem Datenbankserver mit dem Befehl CREATE TABLE {drupal_install_test} (id int NOT NULL PRIMARY KEY) fehlgeschlagen.'); // Now do it successfully. Database::getConnection('default')->query('DROP TABLE {drupal_install_test}'); diff --git a/web/core/tests/Drupal/KernelTests/AssertConfigTrait.php b/web/core/tests/Drupal/KernelTests/AssertConfigTrait.php index eb1588f18..7acab53b0 100644 --- a/web/core/tests/Drupal/KernelTests/AssertConfigTrait.php +++ b/web/core/tests/Drupal/KernelTests/AssertConfigTrait.php @@ -40,7 +40,7 @@ trait AssertConfigTrait { // Allow to skip entire config files. if ($skipped_config[$config_name] === TRUE) { - continue; + break; } // Allow to skip some specific lines of imported config files. @@ -71,12 +71,12 @@ trait AssertConfigTrait { case 'Drupal\Component\Diff\Engine\DiffOpAdd': // The _core property does not exist in the default config. if ($op->closing[0] === '_core:') { - continue; + break; } foreach ($op->closing as $closing) { // The UUIDs don't exist in the default config. if (strpos($closing, 'uuid: ') === 0) { - continue; + break; } throw new \Exception($config_name . ': ' . var_export($op, TRUE)); } diff --git a/web/core/tests/Drupal/KernelTests/Core/Common/SizeTest.php b/web/core/tests/Drupal/KernelTests/Core/Common/SizeTest.php index 82ff53086..f595cfcab 100644 --- a/web/core/tests/Drupal/KernelTests/Core/Common/SizeTest.php +++ b/web/core/tests/Drupal/KernelTests/Core/Common/SizeTest.php @@ -29,8 +29,12 @@ class SizeTest extends KernelTestBase { public function providerTestCommonFormatSize() { $kb = Bytes::KILOBYTE; return [ + ['0 bytes', 0], ['1 byte', 1], + ['-1 bytes', -1], ['2 bytes', 2], + ['-2 bytes', -2], + ['1023 bytes', $kb - 1], ['1 KB', $kb], ['1 MB', pow($kb, 2)], ['1 GB', pow($kb, 3)], @@ -39,10 +43,13 @@ class SizeTest extends KernelTestBase { ['1 EB', pow($kb, 6)], ['1 ZB', pow($kb, 7)], ['1 YB', pow($kb, 8)], - // Rounded to 1 MB - not 1000 or 1024 kilobyte + ['1024 YB', pow($kb, 9)], + // Rounded to 1 MB - not 1000 or 1024 kilobytes ['1 MB', ($kb * $kb) - 1], + ['-1 MB', -(($kb * $kb) - 1)], // Decimal Megabytes ['3.46 MB', 3623651], + ['3.77 GB', 4053371676], // Decimal Petabytes ['59.72 PB', 67234178751368124], // Decimal Yottabytes diff --git a/web/core/tests/Drupal/KernelTests/Core/Entity/EntityFieldTest.php b/web/core/tests/Drupal/KernelTests/Core/Entity/EntityFieldTest.php index 3fd17d86b..1695d9378 100644 --- a/web/core/tests/Drupal/KernelTests/Core/Entity/EntityFieldTest.php +++ b/web/core/tests/Drupal/KernelTests/Core/Entity/EntityFieldTest.php @@ -3,6 +3,7 @@ namespace Drupal\KernelTests\Core\Entity; use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Entity\EntityStorageException; use Drupal\Core\Entity\RevisionLogInterface; use Drupal\Core\Entity\TypedData\EntityDataDefinition; use Drupal\Core\Entity\TypedData\EntityDataDefinitionInterface; @@ -919,4 +920,33 @@ class EntityFieldTest extends EntityKernelTestBase { $this->assertEqual($entity->field_test_text->processed, $target, format_string('%entity_type: Text is processed with the default filter.', ['%entity_type' => $entity_type])); } + /** + * Tests explicit entity ID assignment. + */ + public function testEntityIdAssignment() { + $entity_type = 'entity_test'; + /** @var \Drupal\Core\Entity\ContentEntityStorageInterface $storage */ + $storage = $this->container->get('entity_type.manager')->getStorage($entity_type); + + // Check that an ID can be explicitly assigned on creation. + /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ + $entity = $this->createTestEntity($entity_type); + $entity_id = 3; + $entity->set('id', $entity_id); + $this->assertSame($entity_id, $entity->id()); + $storage->save($entity); + $entity = $storage->loadUnchanged($entity->id()); + $this->assertTrue($entity); + + // Check that an explicitly-assigned ID is preserved on update. + $storage->save($entity); + $entity = $storage->loadUnchanged($entity->id()); + $this->assertTrue($entity); + + // Check that an ID cannot be explicitly assigned on update. + $this->setExpectedException(EntityStorageException::class); + $entity->set('id', $entity_id + 1); + $storage->save($entity); + } + } diff --git a/web/core/tests/Drupal/KernelTests/Core/Entity/EntityQueryTest.php b/web/core/tests/Drupal/KernelTests/Core/Entity/EntityQueryTest.php index e69260489..a6819f019 100644 --- a/web/core/tests/Drupal/KernelTests/Core/Entity/EntityQueryTest.php +++ b/web/core/tests/Drupal/KernelTests/Core/Entity/EntityQueryTest.php @@ -138,8 +138,11 @@ class EntityQueryTest extends EntityKernelTestBase { } foreach (array_reverse(str_split(decbin($i))) as $key => $bit) { if ($bit) { - list($field_name, $langcode, $values) = $units[$key]; - $entity->getTranslation($langcode)->{$field_name}[] = $values; + // @todo https://www.drupal.org/project/drupal/issues/3001920 Doing + // list($field_name, $langcode, $values) = $units[$key]; causes + // problems in PHP 7.3. Revert to better variable names once + // https://bugs.php.net/bug.php?id=76937 is fixed. + $entity->getTranslation($units[$key][1])->{$units[$key][0]}[] = $units[$key][2]; } } $entity->save(); @@ -1160,4 +1163,42 @@ class EntityQueryTest extends EntityKernelTestBase { $this->assertEquals($entity->id(), reset($result)); } + /** + * Tests entity queries with condition on the revision metadata keys. + */ + public function testConditionOnRevisionMetadataKeys() { + $this->installModule('entity_test_revlog'); + $this->installEntitySchema('entity_test_revlog'); + + /** @var \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager */ + $entity_type_manager = $this->container->get('entity_type.manager'); + /** @var \Drupal\Core\Entity\ContentEntityTypeInterface $entity_type */ + $entity_type = $entity_type_manager->getDefinition('entity_test_revlog'); + /** @var \Drupal\Core\Entity\ContentEntityStorageInterface $storage */ + $storage = $entity_type_manager->getStorage('entity_test_revlog'); + + $revision_created_timestamp = time(); + $revision_created_field_name = $entity_type->getRevisionMetadataKey('revision_created'); + $entity = $storage->create([ + 'type' => 'entity_test', + $revision_created_field_name => $revision_created_timestamp, + ]); + $entity->save(); + + // Query only the default revision. + $result = $storage->getQuery() + ->condition($revision_created_field_name, $revision_created_timestamp) + ->execute(); + $this->assertCount(1, $result); + $this->assertEquals($entity->id(), reset($result)); + + // Query all revisions. + $result = $storage->getQuery() + ->condition($revision_created_field_name, $revision_created_timestamp) + ->allRevisions() + ->execute(); + $this->assertCount(1, $result); + $this->assertEquals($entity->id(), reset($result)); + } + } diff --git a/web/core/tests/Drupal/KernelTests/Core/Plugin/EntityContextTypedDataTest.php b/web/core/tests/Drupal/KernelTests/Core/Plugin/EntityContextTypedDataTest.php new file mode 100644 index 000000000..c26a3f8a9 --- /dev/null +++ b/web/core/tests/Drupal/KernelTests/Core/Plugin/EntityContextTypedDataTest.php @@ -0,0 +1,37 @@ + 'entity_test', + 'bundle' => 'entity_test', + 'mode' => 'default', + 'status' => TRUE, + ]); + $display->save(); + + $violations = EntityContext::fromEntity($display)->validate(); + $this->assertCount(0, $violations); + } + +} diff --git a/web/core/tests/Drupal/KernelTests/Core/Theme/MessageTest.php b/web/core/tests/Drupal/KernelTests/Core/Theme/MessageTest.php index 1b3796eff..5552591ed 100644 --- a/web/core/tests/Drupal/KernelTests/Core/Theme/MessageTest.php +++ b/web/core/tests/Drupal/KernelTests/Core/Theme/MessageTest.php @@ -32,6 +32,14 @@ class MessageTest extends KernelTestBase { $this->render($messages); $this->assertRaw('messages messages--error'); $this->assertRaw('messages messages--status'); + // Tests display of only one type of messages. + \Drupal::messenger()->addError('An error occurred'); + $messages = [ + '#type' => 'status_messages', + '#display' => 'error', + ]; + $this->render($messages); + $this->assertRaw('messages messages--error'); } } diff --git a/web/core/tests/Drupal/Tests/BrowserTestBase.php b/web/core/tests/Drupal/Tests/BrowserTestBase.php index 6443b5a38..5417edc04 100644 --- a/web/core/tests/Drupal/Tests/BrowserTestBase.php +++ b/web/core/tests/Drupal/Tests/BrowserTestBase.php @@ -676,7 +676,7 @@ abstract class BrowserTestBase extends TestCase { * The JSON decoded drupalSettings value from the current page. */ protected function getDrupalSettings() { - $html = $this->getSession()->getPage()->getHtml(); + $html = $this->getSession()->getPage()->getContent(); if (preg_match('@@', $html, $matches)) { return Json::decode($matches[1]); } diff --git a/web/core/tests/Drupal/Tests/Component/Utility/MailTest.php b/web/core/tests/Drupal/Tests/Component/Utility/MailTest.php new file mode 100644 index 000000000..5f3032891 --- /dev/null +++ b/web/core/tests/Drupal/Tests/Component/Utility/MailTest.php @@ -0,0 +1,60 @@ +assertEquals($safe_display_name, Mail::formatDisplayName($string)); + } + + /** + * Data provider for testFormatDisplayName(). + * + * @see testFormatDisplayName() + * + * @return array + * An array containing a string and its 'display-name' safe value. + */ + public function providerTestDisplayName() { + return [ + // Simple ASCII characters. + ['Test site', 'Test site'], + // ASCII with html entity. + ['Test & site', 'Test & site'], + // Non-ASCII characters. + ['Tést site', '=?UTF-8?B?VMOpc3Qgc2l0ZQ==?='], + // Non-ASCII with special characters. + ['Tést; site', '=?UTF-8?B?VMOpc3Q7IHNpdGU=?='], + // Non-ASCII with html entity. + ['Tést; site', '=?UTF-8?B?VMOpc3Q7IHNpdGU=?='], + // ASCII with special characters. + ['Test; site', '"Test; site"'], + // ASCII with special characters as html entity. + ['Test < site', '"Test < site"'], + // ASCII with special characters and '\'. + ['Test; \ "site"', '"Test; \\\\ \"site\""'], + // String already RFC-2822 compliant. + ['"Test; site"', '"Test; site"'], + // String already RFC-2822 compliant. + ['"Test; \\\\ \"site\""', '"Test; \\\\ \"site\""'], + ]; + } + +} diff --git a/web/core/tests/Drupal/Tests/Core/Layout/LayoutPluginManagerTest.php b/web/core/tests/Drupal/Tests/Core/Layout/LayoutPluginManagerTest.php index 2d8985914..139200cab 100644 --- a/web/core/tests/Drupal/Tests/Core/Layout/LayoutPluginManagerTest.php +++ b/web/core/tests/Drupal/Tests/Core/Layout/LayoutPluginManagerTest.php @@ -12,6 +12,7 @@ use Drupal\Core\Extension\ThemeHandlerInterface; use Drupal\Core\Layout\LayoutDefault; use Drupal\Core\Layout\LayoutDefinition; use Drupal\Core\Layout\LayoutPluginManager; +use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\Tests\UnitTestCase; use org\bovigo\vfs\vfsStream; use Prophecy\Argument; @@ -114,8 +115,12 @@ class LayoutPluginManagerTest extends UnitTestCase { $theme_a_path = vfsStream::url('root/themes/theme_a'); $layout_definition = $this->layoutPluginManager->getDefinition('theme_a_provided_layout'); $this->assertSame('theme_a_provided_layout', $layout_definition->id()); - $this->assertSame('2 column layout', $layout_definition->getLabel()); - $this->assertSame('Columns: 2', $layout_definition->getCategory()); + $this->assertSame('2 column layout', (string) $layout_definition->getLabel()); + $this->assertSame('Columns: 2', (string) $layout_definition->getCategory()); + $this->assertSame('A theme provided layout', (string) $layout_definition->getDescription()); + $this->assertTrue($layout_definition->getLabel() instanceof TranslatableMarkup); + $this->assertTrue($layout_definition->getCategory() instanceof TranslatableMarkup); + $this->assertTrue($layout_definition->getDescription() instanceof TranslatableMarkup); $this->assertSame('twocol', $layout_definition->getTemplate()); $this->assertSame("$theme_a_path/templates", $layout_definition->getPath()); $this->assertSame('theme_a/twocol', $layout_definition->getLibrary()); @@ -126,19 +131,26 @@ class LayoutPluginManagerTest extends UnitTestCase { $this->assertSame(LayoutDefault::class, $layout_definition->getClass()); $expected_regions = [ 'left' => [ - 'label' => 'Left region', + 'label' => new TranslatableMarkup('Left region', [], ['context' => 'layout_region']), ], 'right' => [ - 'label' => 'Right region', + 'label' => new TranslatableMarkup('Right region', [], ['context' => 'layout_region']), ], ]; - $this->assertSame($expected_regions, $layout_definition->getRegions()); + $regions = $layout_definition->getRegions(); + $this->assertEquals($expected_regions, $regions); + $this->assertTrue($regions['left']['label'] instanceof TranslatableMarkup); + $this->assertTrue($regions['right']['label'] instanceof TranslatableMarkup); $module_a_path = vfsStream::url('root/modules/module_a'); $layout_definition = $this->layoutPluginManager->getDefinition('module_a_provided_layout'); $this->assertSame('module_a_provided_layout', $layout_definition->id()); - $this->assertSame('1 column layout', $layout_definition->getLabel()); - $this->assertSame('Columns: 1', $layout_definition->getCategory()); + $this->assertSame('1 column layout', (string) $layout_definition->getLabel()); + $this->assertSame('Columns: 1', (string) $layout_definition->getCategory()); + $this->assertSame('A module provided layout', (string) $layout_definition->getDescription()); + $this->assertTrue($layout_definition->getLabel() instanceof TranslatableMarkup); + $this->assertTrue($layout_definition->getCategory() instanceof TranslatableMarkup); + $this->assertTrue($layout_definition->getDescription() instanceof TranslatableMarkup); $this->assertSame(NULL, $layout_definition->getTemplate()); $this->assertSame("$module_a_path/layouts", $layout_definition->getPath()); $this->assertSame('module_a/onecol', $layout_definition->getLibrary()); @@ -149,19 +161,26 @@ class LayoutPluginManagerTest extends UnitTestCase { $this->assertSame(LayoutDefault::class, $layout_definition->getClass()); $expected_regions = [ 'top' => [ - 'label' => 'Top region', + 'label' => new TranslatableMarkup('Top region', [], ['context' => 'layout_region']), ], 'bottom' => [ - 'label' => 'Bottom region', + 'label' => new TranslatableMarkup('Bottom region', [], ['context' => 'layout_region']), ], ]; - $this->assertSame($expected_regions, $layout_definition->getRegions()); + $regions = $layout_definition->getRegions(); + $this->assertEquals($expected_regions, $regions); + $this->assertTrue($regions['top']['label'] instanceof TranslatableMarkup); + $this->assertTrue($regions['bottom']['label'] instanceof TranslatableMarkup); $core_path = '/core/lib/Drupal/Core'; $layout_definition = $this->layoutPluginManager->getDefinition('plugin_provided_layout'); $this->assertSame('plugin_provided_layout', $layout_definition->id()); $this->assertEquals('Layout plugin', $layout_definition->getLabel()); $this->assertEquals('Columns: 1', $layout_definition->getCategory()); + $this->assertEquals('Test layout', $layout_definition->getDescription()); + $this->assertTrue($layout_definition->getLabel() instanceof TranslatableMarkup); + $this->assertTrue($layout_definition->getCategory() instanceof TranslatableMarkup); + $this->assertTrue($layout_definition->getDescription() instanceof TranslatableMarkup); $this->assertSame('plugin-provided-layout', $layout_definition->getTemplate()); $this->assertSame($core_path, $layout_definition->getPath()); $this->assertSame(NULL, $layout_definition->getLibrary()); @@ -172,10 +191,12 @@ class LayoutPluginManagerTest extends UnitTestCase { $this->assertSame('Drupal\Core\Plugin\Layout\TestLayout', $layout_definition->getClass()); $expected_regions = [ 'main' => [ - 'label' => 'Main Region', + 'label' => new TranslatableMarkup('Main Region', [], ['context' => 'layout_region']), ], ]; - $this->assertEquals($expected_regions, $layout_definition->getRegions()); + $regions = $layout_definition->getRegions(); + $this->assertEquals($expected_regions, $regions); + $this->assertTrue($regions['main']['label'] instanceof TranslatableMarkup); } /** @@ -284,6 +305,7 @@ EOS; module_a_provided_layout: label: 1 column layout category: 'Columns: 1' + description: 'A module provided layout' theme_hook: onecol path: layouts library: module_a/onecol @@ -301,6 +323,7 @@ theme_a_provided_layout: class: '\Drupal\Core\Layout\LayoutDefault' label: 2 column layout category: 'Columns: 2' + description: 'A theme provided layout' template: twocol path: templates library: theme_a/twocol @@ -325,7 +348,7 @@ use Drupal\Core\Layout\LayoutDefault; * template = "templates/plugin-provided-layout", * regions = { * "main" = { - * "label" = @Translation("Main Region") + * "label" = @Translation("Main Region", context = "layout_region") * } * } * ) diff --git a/web/core/tests/Drupal/Tests/Core/Plugin/Context/ContextAwarePluginBaseTest.php b/web/core/tests/Drupal/Tests/Core/Plugin/Context/ContextAwarePluginBaseTest.php new file mode 100644 index 000000000..66e54b215 --- /dev/null +++ b/web/core/tests/Drupal/Tests/Core/Plugin/Context/ContextAwarePluginBaseTest.php @@ -0,0 +1,96 @@ +plugin = new TestContextAwarePlugin([], 'the_sisko', new TestPluginDefinition()); + } + + /** + * @covers ::getContextDefinitions + */ + public function testGetContextDefinitions() { + $this->assertInternalType('array', $this->plugin->getContextDefinitions()); + } + + /** + * @covers ::getContextDefinition + */ + public function testGetContextDefinition() { + // The context is not defined, so an exception will be thrown. + $this->setExpectedException(ContextException::class, 'The person context is not a valid context.'); + $this->plugin->getContextDefinition('person'); + } + + /** + * @covers ::setContextValue + */ + public function testSetContextValue() { + $typed_data_manager = $this->prophesize(TypedDataManagerInterface::class); + $container = new ContainerBuilder(); + $container->set('typed_data_manager', $typed_data_manager->reveal()); + \Drupal::setContainer($container); + + $this->plugin->getPluginDefinition()->addContextDefinition('foo', new ContextDefinition('string')); + + $this->assertFalse($this->plugin->setContextCalled); + $this->plugin->setContextValue('foo', new StringData(new DataDefinition(), 'bar')); + $this->assertTrue($this->plugin->setContextCalled); + } + +} + +class TestPluginDefinition extends PluginDefinition implements ContextAwarePluginDefinitionInterface { + + use ContextAwarePluginDefinitionTrait; + +} + +class TestContextAwarePlugin extends ContextAwarePluginBase { + + /** + * Indicates if ::setContext() has been called or not. + * + * @var bool + */ + public $setContextCalled = FALSE; + + /** + * {@inheritdoc} + */ + public function setContext($name, ComponentContextInterface $context) { + parent::setContext($name, $context); + $this->setContextCalled = TRUE; + } + +} diff --git a/web/core/tests/Drupal/Tests/Core/Routing/RouterTest.php b/web/core/tests/Drupal/Tests/Core/Routing/RouterTest.php new file mode 100644 index 000000000..d6ba1d43f --- /dev/null +++ b/web/core/tests/Drupal/Tests/Core/Routing/RouterTest.php @@ -0,0 +1,60 @@ +prophesize(RouteProviderInterface::class); + + $route_collection = new RouteCollection(); + + $route = new Route('/user/{user}'); + $route->setOption('compiler_class', RouteCompiler::class); + $route_collection->add('user_view', $route); + + $route = new Route('/user/login'); + $route->setOption('compiler_class', RouteCompiler::class); + $route_collection->add('user_login', $route); + + $route_provider->getRouteCollectionForRequest(Argument::any()) + ->willReturn($route_collection); + + $url_generator = $this->prophesize(UrlGeneratorInterface::class); + $current_path_stack = $this->prophesize(CurrentPathStack::class); + $router = new Router($route_provider->reveal(), $current_path_stack->reveal(), $url_generator->reveal()); + + $request_context = $this->prophesize(RequestContext::class); + $request_context->getScheme()->willReturn('http'); + $router->setContext($request_context->reveal()); + + $current_path_stack->getPath(Argument::any())->willReturn('/user/1'); + $result = $router->match('/user/1'); + + $this->assertEquals('user_view', $result['_route']); + + $current_path_stack->getPath(Argument::any())->willReturn('/user/login'); + $result = $router->match('/user/login'); + + $this->assertEquals('user_login', $result['_route']); + } + +} diff --git a/web/core/tests/Drupal/Tests/UiHelperTrait.php b/web/core/tests/Drupal/Tests/UiHelperTrait.php index d0817f607..d315433ac 100644 --- a/web/core/tests/Drupal/Tests/UiHelperTrait.php +++ b/web/core/tests/Drupal/Tests/UiHelperTrait.php @@ -167,27 +167,12 @@ trait UiHelperTrait { * @todo change $edit to disallow NULL as a value for Drupal 9. * https://www.drupal.org/node/2802401 * @param string $submit - * Value of the submit button whose click is to be emulated. For example, - * 'Save'. The processing of the request depends on this value. For example, - * a form may have one button with the value 'Save' and another button with - * the value 'Delete', and execute different code depending on which one is - * clicked. - * - * This function can also be called to emulate an Ajax submission. In this - * case, this value needs to be an array with the following keys: - * - path: A path to submit the form values to for Ajax-specific processing. - * - triggering_element: If the value for the 'path' key is a generic Ajax - * processing path, this needs to be set to the name of the element. If - * the name doesn't identify the element uniquely, then this should - * instead be an array with a single key/value pair, corresponding to the - * element name and value. The \Drupal\Core\Form\FormAjaxResponseBuilder - * uses this to find the #ajax information for the element, including - * which specific callback to use for processing the request. - * - * This can also be set to NULL in order to emulate an Internet Explorer - * submission of a form with a single text field, and pressing ENTER in that - * textfield: under these conditions, no button information is added to the - * POST data. + * The id, name, label or value of the submit button which is to be clicked. + * For example, 'Save'. The first element matched by + * \Drupal\Tests\WebAssert::buttonExists() will be used. The processing of + * the request depends on this value. For example, a form may have one + * button with the value 'Save' and another button with the value 'Delete', + * and execute different code depending on which one is clicked. * @param array $options * Options to be forwarded to the url generator. * @param string|null $form_html_id @@ -202,6 +187,8 @@ trait UiHelperTrait { * (deprecated) The response content after submit form. It is necessary for * backwards compatibility and will be removed before Drupal 9.0. You should * just use the webAssert object for your assertions. + * + * @see \Drupal\Tests\WebAssert::buttonExists() */ protected function drupalPostForm($path, $edit, $submit, array $options = [], $form_html_id = NULL) { if (is_object($submit)) { diff --git a/web/core/tests/Drupal/Tests/WebAssert.php b/web/core/tests/Drupal/Tests/WebAssert.php index 19d9c2cc6..fa7700ece 100644 --- a/web/core/tests/Drupal/Tests/WebAssert.php +++ b/web/core/tests/Drupal/Tests/WebAssert.php @@ -3,6 +3,7 @@ namespace Drupal\Tests; use Behat\Mink\Exception\ExpectationException; +use Behat\Mink\Exception\ResponseTextException; use Behat\Mink\WebAssert as MinkWebAssert; use Behat\Mink\Element\TraversableElement; use Behat\Mink\Exception\ElementNotFoundException; @@ -545,4 +546,31 @@ class WebAssert extends MinkWebAssert { $this->assert(!preg_match($regex, $actual), $message); } + /** + * Checks that current page contains text only once. + * + * @param string $text + * The string to look for. + * + * @see \Behat\Mink\WebAssert::pageTextContains() + */ + public function pageTextContainsOnce($text) { + $actual = $this->session->getPage()->getText(); + $actual = preg_replace('/\s+/u', ' ', $actual); + $regex = '/' . preg_quote($text, '/') . '/ui'; + $count = preg_match_all($regex, $actual); + if ($count === 1) { + return; + } + + if ($count > 1) { + $message = sprintf('The text "%s" appears in the text of this page more than once, but it should not.', $text); + } + else { + $message = sprintf('The text "%s" was not found anywhere in the text of the current page.', $text); + } + + throw new ResponseTextException($message, $this->session->getDriver()); + } + } diff --git a/web/core/themes/bartik/bartik.theme b/web/core/themes/bartik/bartik.theme index 1c408cf3c..76394c16a 100644 --- a/web/core/themes/bartik/bartik.theme +++ b/web/core/themes/bartik/bartik.theme @@ -37,7 +37,7 @@ function bartik_preprocess_html(&$variables) { } /** - * Implements hook_preprocess_HOOK() for page templates. + * Implements hook_preprocess_HOOK() for page title templates. */ function bartik_preprocess_page_title(&$variables) { // Since the title and the shortcut link are both block level elements, diff --git a/web/robots.txt b/web/robots.txt index 13483a81c..54da16277 100644 --- a/web/robots.txt +++ b/web/robots.txt @@ -42,7 +42,7 @@ Disallow: /web.config # Paths (clean URLs) Disallow: /admin/ Disallow: /comment/reply/ -Disallow: /filter/tips/ +Disallow: /filter/tips Disallow: /node/add/ Disallow: /search/ Disallow: /user/register/ @@ -52,7 +52,7 @@ Disallow: /user/logout/ # Paths (no clean URLs) Disallow: /index.php/admin/ Disallow: /index.php/comment/reply/ -Disallow: /index.php/filter/tips/ +Disallow: /index.php/filter/tips Disallow: /index.php/node/add/ Disallow: /index.php/search/ Disallow: /index.php/user/password/ diff --git a/web/sites/example.sites.php b/web/sites/example.sites.php index e4de67eb9..daaf68272 100644 --- a/web/sites/example.sites.php +++ b/web/sites/example.sites.php @@ -26,9 +26,9 @@ * example, to map https://www.drupal.org:8080/mysite/test to the configuration * directory sites/example.com, the array should be defined as: * @code - * $sites = array( + * $sites = [ * '8080.www.drupal.org.mysite.test' => 'example.com', - * ); + * ]; * @endcode * The URL, https://www.drupal.org:8080/mysite/test/, could be a symbolic link * or an Apache Alias directive that points to the Drupal root containing