diff --git a/core/misc/cspell/dictionary.txt b/core/misc/cspell/dictionary.txt index 114bed611f..4d0c5e26e8 100644 --- a/core/misc/cspell/dictionary.txt +++ b/core/misc/cspell/dictionary.txt @@ -1,4 +1,4 @@ -abcdefghijklmno + abcdefghijklmno abcdefghijkmnopqrstuvwxyz abcdefghjklmnpqrstuvwxyz absolutezero @@ -704,6 +704,7 @@ montag monthnames mooooh mostrar +mouseup moutons moyenne msgctxt diff --git a/core/modules/layout_builder/layout_builder.links.contextual.yml b/core/modules/layout_builder/layout_builder.links.contextual.yml index 4bbfcc9e64..b43b81ea69 100644 --- a/core/modules/layout_builder/layout_builder.links.contextual.yml +++ b/core/modules/layout_builder/layout_builder.links.contextual.yml @@ -27,3 +27,23 @@ layout_builder_block_remove: class: ['use-ajax'] data-dialog-type: dialog data-dialog-renderer: off_canvas + +layout_builder_block_translate: + title: 'Translate block' + route_name: 'layout_builder.translate_block' + group: 'layout_builder_block_translation' + options: + attributes: + class: ['use-ajax'] + data-dialog-type: dialog + data-dialog-renderer: off_canvas + +layout_builder_inline_block_translate: + title: 'Translate block' + route_name: 'layout_builder.translate_inline_block' + group: 'layout_builder_inline_block_translation' + options: + attributes: + class: ['use-ajax'] + data-dialog-type: dialog + data-dialog-renderer: off_canvas diff --git a/core/modules/layout_builder/layout_builder.module b/core/modules/layout_builder/layout_builder.module index 2f7cc35620..6e76e6b184 100644 --- a/core/modules/layout_builder/layout_builder.module +++ b/core/modules/layout_builder/layout_builder.module @@ -19,6 +19,7 @@ use Drupal\layout_builder\Entity\LayoutBuilderEntityViewDisplay; use Drupal\layout_builder\Entity\LayoutBuilderEntityViewDisplayStorage; use Drupal\layout_builder\Entity\LayoutEntityDisplayInterface; +use Drupal\layout_builder\Form\BlockContentInlineBlockTranslateForm; use Drupal\layout_builder\Form\DefaultsEntityForm; use Drupal\layout_builder\Form\LayoutBuilderEntityViewDisplayForm; use Drupal\layout_builder\Form\OverridesEntityForm; @@ -84,6 +85,10 @@ function layout_builder_entity_type_alter(array &$entity_types) { $entity_type->setFormClass('layout_builder', OverridesEntityForm::class); } } + + if (isset($entity_types['block_content'])) { + $entity_types['block_content']->setFormClass('layout_builder_translate', BlockContentInlineBlockTranslateForm::class); + } } /** diff --git a/core/modules/layout_builder/layout_builder.post_update.php b/core/modules/layout_builder/layout_builder.post_update.php index 5d500ddd0f..9aaf08af69 100644 --- a/core/modules/layout_builder/layout_builder.post_update.php +++ b/core/modules/layout_builder/layout_builder.post_update.php @@ -8,7 +8,10 @@ use Drupal\Core\Config\Entity\ConfigEntityUpdater; use Drupal\Core\Entity\Display\EntityViewDisplayInterface; use Drupal\Core\Field\Plugin\Field\FieldFormatter\TimestampFormatter; +use Drupal\field\Entity\FieldConfig; +use Drupal\field\Entity\FieldStorageConfig; use Drupal\layout_builder\Entity\LayoutEntityDisplayInterface; +use Drupal\layout_builder\Plugin\SectionStorage\OverridesSectionStorage; /** * Implements hook_removed_post_updates(). @@ -36,6 +39,63 @@ function layout_builder_removed_post_updates() { ]; } +/** + * Adds the layout translation settings field. + */ +function layout_builder_post_update_add_translation_field() { + /** @var \Drupal\Core\Entity\EntityFieldManagerInterface $field_manager */ + $field_manager = \Drupal::service('entity_field.manager'); + $field_map = $field_manager->getFieldMap(); + foreach ($field_map as $entity_type_id => $field_infos) { + if (isset($field_infos[OverridesSectionStorage::FIELD_NAME]['bundles'])) { + foreach ($field_infos[OverridesSectionStorage::FIELD_NAME]['bundles'] as $bundle) { + // The field map can contain stale information. If the field does not + // exist, ignore it. The field map will be rebuilt when the cache is + // cleared at the end of the update process. + if (!FieldConfig::loadByName($entity_type_id, $bundle, OverridesSectionStorage::FIELD_NAME)) { + continue; + } + _layout_builder_add_translation_field($entity_type_id, $bundle); + + } + } + + } +} + +/** + * Adds a layout translation field to a given bundle. + * + * @param string $entity_type_id + * The entity type ID. + * @param string $bundle + * The bundle. + */ +function _layout_builder_add_translation_field($entity_type_id, $bundle) { + $field_name = OverridesSectionStorage::TRANSLATED_CONFIGURATION_FIELD_NAME; + $field = FieldConfig::loadByName($entity_type_id, $bundle, $field_name); + if (!$field) { + $field_storage = FieldStorageConfig::loadByName($entity_type_id, $field_name); + if (!$field_storage) { + $field_storage = FieldStorageConfig::create([ + 'entity_type' => $entity_type_id, + 'field_name' => $field_name, + 'type' => 'layout_translation', + 'locked' => TRUE, + ]); + $field_storage->setTranslatable(TRUE); + $field_storage->save(); + } + + $field = FieldConfig::create([ + 'field_storage' => $field_storage, + 'bundle' => $bundle, + 'label' => t('Layout Labels'), + ]); + $field->save(); + } +} + /** * Update timestamp formatter settings for Layout Builder fields. */ diff --git a/core/modules/layout_builder/layout_builder.routing.yml b/core/modules/layout_builder/layout_builder.routing.yml index fa72dcec93..5db3e9692b 100644 --- a/core/modules/layout_builder/layout_builder.routing.yml +++ b/core/modules/layout_builder/layout_builder.routing.yml @@ -5,6 +5,7 @@ layout_builder.choose_section: _title: 'Choose a layout for this section' requirements: _layout_builder_access: 'view' + _layout_builder_translation_access: 'untranslated' options: _admin_route: TRUE parameters: @@ -17,6 +18,7 @@ layout_builder.add_section: _controller: '\Drupal\layout_builder\Controller\AddSectionController::build' requirements: _layout_builder_access: 'view' + _layout_builder_translation_access: 'untranslated' options: _admin_route: TRUE parameters: @@ -33,6 +35,7 @@ layout_builder.configure_section: plugin_id: null requirements: _layout_builder_access: 'view' + _layout_builder_translation_access: 'untranslated' options: _admin_route: TRUE parameters: @@ -45,6 +48,7 @@ layout_builder.remove_section: _form: '\Drupal\layout_builder\Form\RemoveSectionForm' requirements: _layout_builder_access: 'view' + _layout_builder_translation_access: 'untranslated' options: _admin_route: TRUE parameters: @@ -58,6 +62,7 @@ layout_builder.choose_block: _title: 'Choose a block' requirements: _layout_builder_access: 'view' + _layout_builder_translation_access: 'untranslated' options: _admin_route: TRUE parameters: @@ -71,6 +76,7 @@ layout_builder.add_block: _title: 'Configure block' requirements: _layout_builder_access: 'view' + _layout_builder_translation_access: 'untranslated' options: _admin_route: TRUE parameters: @@ -84,6 +90,7 @@ layout_builder.choose_inline_block: _title: 'Add a new content block' requirements: _layout_builder_access: 'view' + _layout_builder_translation_access: 'untranslated' options: _admin_route: TRUE parameters: @@ -97,6 +104,35 @@ layout_builder.update_block: _title: 'Configure block' requirements: _layout_builder_access: 'view' + _layout_builder_translation_access: 'untranslated' + options: + _admin_route: TRUE + parameters: + section_storage: + layout_builder_tempstore: TRUE + +layout_builder.translate_block: + path: '/layout_builder/translate/block/{section_storage_type}/{section_storage}/{delta}/{region}/{uuid}/{langcode}' + defaults: + _form: '\Drupal\layout_builder\Form\TranslateBlockForm' + _title: 'Translate block' + requirements: + _layout_builder_access: 'view' + _layout_builder_translation_access: 'translated' + options: + _admin_route: TRUE + parameters: + section_storage: + layout_builder_tempstore: TRUE + +layout_builder.translate_inline_block: + path: '/layout_builder/translate/inline-block/{section_storage_type}/{section_storage}/{delta}/{region}/{uuid}' + defaults: + _entity_form: 'block_content.layout_builder_translate' + _title: 'Translate block' + requirements: + _layout_builder_access: 'view' + _layout_builder_translation_access: 'translated' options: _admin_route: TRUE parameters: @@ -110,6 +146,7 @@ layout_builder.move_block_form: _form: '\Drupal\layout_builder\Form\MoveBlockForm' requirements: _layout_builder_access: 'view' + _layout_builder_translation_access: 'untranslated' options: _admin_route: TRUE parameters: @@ -122,6 +159,7 @@ layout_builder.remove_block: _form: '\Drupal\layout_builder\Form\RemoveBlockForm' requirements: _layout_builder_access: 'view' + _layout_builder_translation_access: 'untranslated' options: _admin_route: TRUE parameters: diff --git a/core/modules/layout_builder/layout_builder.services.yml b/core/modules/layout_builder/layout_builder.services.yml index 4e9fc3acda..6be504fb1e 100644 --- a/core/modules/layout_builder/layout_builder.services.yml +++ b/core/modules/layout_builder/layout_builder.services.yml @@ -6,6 +6,10 @@ services: class: Drupal\layout_builder\Access\LayoutBuilderAccessCheck tags: - { name: access_check, applies_to: _layout_builder_access } + access_check.entity.layout_builder_translation_access: + class: Drupal\layout_builder\Access\LayoutBuilderTranslationAccessCheck + tags: + - { name: access_check, applies_to: _layout_builder_translation_access } plugin.manager.layout_builder.section_storage: class: Drupal\layout_builder\SectionStorage\SectionStorageManager parent: default_plugin_manager @@ -43,6 +47,11 @@ services: arguments: ['@current_user'] tags: - { name: event_subscriber } + layout_builder.translate_block_component_subscriber: + class: Drupal\layout_builder\EventSubscriber\ComponentPluginTranslate + arguments: ['@language_manager', '@current_route_match'] + tags: + - { name: event_subscriber } logger.channel.layout_builder: parent: logger.channel_base arguments: ['layout_builder'] diff --git a/core/modules/layout_builder/src/Access/LayoutBuilderLockAccessCheck.php b/core/modules/layout_builder/src/Access/LayoutBuilderLockAccessCheck.php new file mode 100644 index 0000000000..7f8e8f403b --- /dev/null +++ b/core/modules/layout_builder/src/Access/LayoutBuilderLockAccessCheck.php @@ -0,0 +1,54 @@ +<?php + +namespace Drupal\layout_builder\Access; + +use Drupal\Core\Access\AccessResult; +use Drupal\Core\Routing\Access\AccessInterface; +use Drupal\Core\Session\AccountInterface; +use Drupal\layout_builder\LayoutTempstoreRepositoryInterface; +use Drupal\layout_builder\SectionStorageInterface; + +/** + * Checks access based on whether the layout is locked. + * + * @ingroup layout_builder_access + * + * @internal + * Tagged services are internal. + */ +class LayoutBuilderLockAccessCheck implements AccessInterface { + + /** + * The layout tempstore repository. + * + * @var \Drupal\layout_builder\LayoutTempstoreRepositoryInterface + */ + protected $layoutTempstoreRepository; + + /** + * Constructs a LayoutBuilderLockAccessCheck. + * + * @param \Drupal\layout_builder\LayoutTempstoreRepositoryInterface $layout_tempstore_repository + * The layout tempstore repository. + */ + public function __construct(LayoutTempstoreRepositoryInterface $layout_tempstore_repository) { + $this->layoutTempstoreRepository = $layout_tempstore_repository; + } + + /** + * Checks for a lock on the layout. + * + * @param \Drupal\layout_builder\SectionStorageInterface $section_storage + * The section storage. + * @param \Drupal\Core\Session\AccountInterface $account + * The current user. + * + * @return \Drupal\Core\Access\AccessResultInterface + * The access result. + */ + public function access(SectionStorageInterface $section_storage, AccountInterface $account) { + $lock = $this->layoutTempstoreRepository->getLock($section_storage); + return AccessResult::allowedIf(!$lock || $account->id() === $lock->getOwnerId())->setCacheMaxAge(0); + } + +} diff --git a/core/modules/layout_builder/src/Access/LayoutBuilderTranslationAccessCheck.php b/core/modules/layout_builder/src/Access/LayoutBuilderTranslationAccessCheck.php new file mode 100644 index 0000000000..eaddc44e33 --- /dev/null +++ b/core/modules/layout_builder/src/Access/LayoutBuilderTranslationAccessCheck.php @@ -0,0 +1,52 @@ +<?php + +namespace Drupal\layout_builder\Access; + +use Drupal\Core\Access\AccessResult; +use Drupal\Core\Routing\Access\AccessInterface; +use Drupal\layout_builder\LayoutEntityHelperTrait; +use Drupal\layout_builder\SectionStorageInterface; +use Symfony\Component\Routing\Route; + +/** + * Provides an access check for the Layout Builder translations. + * + * @ingroup layout_builder_access + * + * @internal + * Tagged services are internal. + */ +class LayoutBuilderTranslationAccessCheck implements AccessInterface { + + use LayoutEntityHelperTrait; + + /** + * Checks routing access to the default translation only layout. + * + * @param \Drupal\layout_builder\SectionStorageInterface $section_storage + * The section storage. + * @param \Symfony\Component\Routing\Route $route + * The route to check against. + * + * @return \Drupal\Core\Access\AccessResultInterface + * The access result. + */ + public function access(SectionStorageInterface $section_storage, Route $route) { + $translation_type = $route->getRequirement('_layout_builder_translation_access'); + $is_translation = static::isTranslation($section_storage); + switch ($translation_type) { + case 'untranslated': + $access = AccessResult::allowedIf(!$is_translation); + break; + + case 'translated': + $access = AccessResult::allowedIf($is_translation); + break; + + default: + throw new \UnexpectedValueException("Unexpected _layout_builder_translation_access route requirement: $translation_type"); + } + return $access; + } + +} diff --git a/core/modules/layout_builder/src/Element/LayoutBuilder.php b/core/modules/layout_builder/src/Element/LayoutBuilder.php index 8e192b7a1e..cdb78b835b 100644 --- a/core/modules/layout_builder/src/Element/LayoutBuilder.php +++ b/core/modules/layout_builder/src/Element/LayoutBuilder.php @@ -2,7 +2,9 @@ namespace Drupal\layout_builder\Element; +use Drupal\Component\Plugin\DerivativeInspectionInterface; use Drupal\Core\Ajax\AjaxHelperTrait; +use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\Core\Plugin\PluginFormInterface; use Drupal\Core\Render\Element; @@ -12,6 +14,7 @@ use Drupal\layout_builder\Event\PrepareLayoutEvent; use Drupal\layout_builder\LayoutBuilderEvents; use Drupal\layout_builder\LayoutBuilderHighlightTrait; +use Drupal\layout_builder\LayoutEntityHelperTrait; use Drupal\layout_builder\SectionStorageInterface; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; @@ -29,6 +32,7 @@ class LayoutBuilder extends RenderElement implements ContainerFactoryPluginInter use AjaxHelperTrait; use LayoutBuilderContextTrait; use LayoutBuilderHighlightTrait; + use LayoutEntityHelperTrait; /** * The event dispatcher. @@ -37,6 +41,13 @@ class LayoutBuilder extends RenderElement implements ContainerFactoryPluginInter */ protected $eventDispatcher; + /** + * The entity type manager. + * + * @var \Drupal\Core\Entity\EntityTypeManagerInterface + */ + protected $entityTypeManager; + /** * Constructs a new LayoutBuilder. * @@ -48,10 +59,13 @@ class LayoutBuilder extends RenderElement implements ContainerFactoryPluginInter * The plugin implementation definition. * @param \Symfony\Contracts\EventDispatcher\EventDispatcherInterface $event_dispatcher * The event dispatcher service. + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager + * The entity type manager. */ - public function __construct(array $configuration, $plugin_id, $plugin_definition, EventDispatcherInterface $event_dispatcher) { + public function __construct(array $configuration, $plugin_id, $plugin_definition, EventDispatcherInterface $event_dispatcher, EntityTypeManagerInterface $entity_type_manager) { parent::__construct($configuration, $plugin_id, $plugin_definition); $this->eventDispatcher = $event_dispatcher; + $this->entityTypeManager = $entity_type_manager; } /** @@ -62,7 +76,8 @@ public static function create(ContainerInterface $container, array $configuratio $configuration, $plugin_id, $plugin_definition, - $container->get('event_dispatcher') + $container->get('event_dispatcher'), + $container->get('entity_type.manager') ); } @@ -99,6 +114,7 @@ public function preRender($element) { */ protected function layout(SectionStorageInterface $section_storage) { $this->prepareLayout($section_storage); + $is_translation = static::isTranslation($section_storage); $output = []; if ($this->isAjax()) { @@ -108,11 +124,15 @@ protected function layout(SectionStorageInterface $section_storage) { } $count = 0; for ($i = 0; $i < $section_storage->count(); $i++) { - $output[] = $this->buildAddSectionLink($section_storage, $count); + if (!$is_translation) { + $output[] = $this->buildAddSectionLink($section_storage, $count); + } $output[] = $this->buildAdministrativeSection($section_storage, $count); $count++; } - $output[] = $this->buildAddSectionLink($section_storage, $count); + if (!$is_translation) { + $output[] = $this->buildAddSectionLink($section_storage, $count); + } $output['#attached']['library'][] = 'layout_builder/drupal.layout_builder'; // As the Layout Builder UI is typically displayed using the frontend theme, // it is not marked as an administrative page at the route level even though @@ -124,6 +144,7 @@ protected function layout(SectionStorageInterface $section_storage) { $output['#attributes']['class'][] = 'layout-builder'; // Mark this UI as uncacheable. $output['#cache']['max-age'] = 0; + return $output; } @@ -222,6 +243,8 @@ protected function buildAdministrativeSection(SectionStorageInterface $section_s $section = $section_storage->getSection($delta); $layout = $section->getLayout($this->getPopulatedContexts($section_storage)); + $sections_editable = !static::isTranslation($section_storage); + $layout = $section->getLayout(); $layout_settings = $section->getLayoutSettings(); $section_label = !empty($layout_settings['label']) ? $layout_settings['label'] : $this->t('Section @section', ['@section' => $delta + 1]); @@ -232,33 +255,19 @@ protected function buildAdministrativeSection(SectionStorageInterface $section_s foreach ($layout_definition->getRegions() as $region => $info) { if (!empty($build[$region])) { foreach (Element::children($build[$region]) as $uuid) { - $build[$region][$uuid]['#attributes']['class'][] = 'js-layout-builder-block'; + if ($sections_editable) { + $build[$region][$uuid]['#attributes']['class'][] = 'js-layout-builder-block'; + } $build[$region][$uuid]['#attributes']['class'][] = 'layout-builder-block'; $build[$region][$uuid]['#attributes']['data-layout-block-uuid'] = $uuid; $build[$region][$uuid]['#attributes']['data-layout-builder-highlight-id'] = $this->blockUpdateHighlightId($uuid); - $build[$region][$uuid]['#contextual_links'] = [ - 'layout_builder_block' => [ - 'route_parameters' => [ - 'section_storage_type' => $storage_type, - 'section_storage' => $storage_id, - 'delta' => $delta, - 'region' => $region, - 'uuid' => $uuid, - ], - // Add metadata about the current operations available in - // contextual links. This will invalidate the client-side cache of - // links that were cached before the 'move' link was added. - // @see layout_builder.links.contextual.yml - 'metadata' => [ - 'operations' => 'move:update:remove', - ], - ], - ]; + $build[$region][$uuid]['#contextual_links'] = $this->createContextualLinkElement($section_storage, $delta, $region, $uuid); } } $build[$region]['layout_builder_add_block']['link'] = [ '#type' => 'link', + '#access' => $sections_editable, // Add one to the current delta since it is zero-indexed. '#title' => $this->t('Add block <span class="visually-hidden">in @section, @region region</span>', ['@section' => $section_label, '@region' => $region_labels[$region]]), '#url' => Url::fromRoute('layout_builder.choose_block', @@ -334,6 +343,7 @@ protected function buildAdministrativeSection(SectionStorageInterface $section_s ], 'remove' => [ '#type' => 'link', + '#access' => $sections_editable, '#title' => $this->t('Remove @section', ['@section' => $section_label]), '#url' => Url::fromRoute('layout_builder.remove_section', [ 'section_storage_type' => $storage_type, @@ -358,8 +368,8 @@ protected function buildAdministrativeSection(SectionStorageInterface $section_s ], 'configure' => [ '#type' => 'link', + '#access' => $layout instanceof PluginFormInterface && $sections_editable, '#title' => $this->t('Configure @section', ['@section' => $section_label]), - '#access' => $layout instanceof PluginFormInterface, '#url' => Url::fromRoute('layout_builder.configure_section', [ 'section_storage_type' => $storage_type, 'section_storage' => $storage_id, @@ -379,4 +389,67 @@ protected function buildAdministrativeSection(SectionStorageInterface $section_s ]; } + /** + * Creates contextual link element for a component. + * + * @param \Drupal\layout_builder\SectionStorageInterface $section_storage + * The section storage. + * @param $delta + * The section delta. + * @param $region + * The region. + * @param $uuid + * The UUID of the component. + * @param $is_translation + * Whether the section storage is handling a translation. + * + * @return array|null + * The contextual link render array or NULL if none. + */ + protected function createContextualLinkElement(SectionStorageInterface $section_storage, $delta, $region, $uuid) { + $section = $section_storage->getSection($delta); + $contextual_link_settings = [ + 'route_parameters' => [ + 'section_storage_type' => $section_storage->getStorageType(), + 'section_storage' => $section_storage->getStorageId(), + 'delta' => $delta, + 'region' => $region, + 'uuid' => $uuid, + ], + ]; + if (static::isTranslation($section_storage)) { + $contextual_group = 'layout_builder_block_translation'; + $component = $section->getComponent($uuid); + /** @var \Drupal\Core\Language\LanguageInterface $language */ + if ($language = $section_storage->getTranslationLanguage()) { + $contextual_link_settings['route_parameters']['langcode'] = $language->getId(); + } + + /** @var \Drupal\layout_builder\Plugin\Block\InlineBlock $plugin */ + $plugin = $component->getPlugin(); + if ($plugin instanceof DerivativeInspectionInterface && $plugin->getBaseId() === 'inline_block') { + $configuration = $plugin->getConfiguration(); + /** @var \Drupal\block_content\Entity\BlockContent $block */ + $block = $this->entityTypeManager->getStorage('block_content') + ->loadRevision($configuration['block_revision_id']); + if ($block && $block->isTranslatable()) { + $contextual_group = 'layout_builder_inline_block_translation'; + } + } + } + else { + $contextual_group = 'layout_builder_block'; + // Add metadata about the current operations available in + // contextual links. This will invalidate the client-side cache of + // links that were cached before the 'move' link was added. + // @see layout_builder.links.contextual.yml + $contextual_link_settings['metadata'] = [ + 'operations' => 'move:update:remove', + ]; + } + return [ + $contextual_group => $contextual_link_settings, + ]; + } + } diff --git a/core/modules/layout_builder/src/Entity/LayoutBuilderEntityViewDisplay.php b/core/modules/layout_builder/src/Entity/LayoutBuilderEntityViewDisplay.php index 457705a791..1bf2b022c3 100644 --- a/core/modules/layout_builder/src/Entity/LayoutBuilderEntityViewDisplay.php +++ b/core/modules/layout_builder/src/Entity/LayoutBuilderEntityViewDisplay.php @@ -218,6 +218,41 @@ protected function addSectionField($entity_type_id, $bundle, $field_name) { $field->setTranslatable(FALSE); $field->save(); } + $this->addTranslationField($entity_type_id, $bundle, OverridesSectionStorage::TRANSLATED_CONFIGURATION_FIELD_NAME); + } + + /** + * Adds a layout translation field to a given bundle. + * + * @param string $entity_type_id + * The entity type ID. + * @param string $bundle + * The bundle. + * @param string $field_name + * The name for the translation field. + */ + protected function addTranslationField($entity_type_id, $bundle, $field_name) { + $field = FieldConfig::loadByName($entity_type_id, $bundle, $field_name); + if (!$field) { + $field_storage = FieldStorageConfig::loadByName($entity_type_id, $field_name); + if (!$field_storage) { + $field_storage = FieldStorageConfig::create([ + 'entity_type' => $entity_type_id, + 'field_name' => $field_name, + 'type' => 'layout_translation', + 'locked' => TRUE, + ]); + $field_storage->setTranslatable(TRUE); + $field_storage->save(); + } + + $field = FieldConfig::create([ + 'field_storage' => $field_storage, + 'bundle' => $bundle, + 'label' => t('Layout Labels'), + ]); + $field->save(); + } } /** diff --git a/core/modules/layout_builder/src/EventSubscriber/ComponentPluginTranslate.php b/core/modules/layout_builder/src/EventSubscriber/ComponentPluginTranslate.php new file mode 100644 index 0000000000..2db9ecc3be --- /dev/null +++ b/core/modules/layout_builder/src/EventSubscriber/ComponentPluginTranslate.php @@ -0,0 +1,93 @@ +<?php + +namespace Drupal\layout_builder\EventSubscriber; + +use Drupal\Component\Plugin\ConfigurableInterface; +use Drupal\Core\Language\LanguageManagerInterface; +use Drupal\Core\Routing\RouteMatchInterface; +use Drupal\layout_builder\Event\SectionComponentBuildRenderArrayEvent; +use Drupal\layout_builder\LayoutBuilderEvents; +use Drupal\layout_builder\LayoutEntityHelperTrait; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Translates the plugin configuration if needed. + * + * @internal + * Tagged services are internal. + */ +class ComponentPluginTranslate implements EventSubscriberInterface { + + use LayoutEntityHelperTrait; + + /** + * The language manager. + * + * @var \Drupal\Core\Language\LanguageManagerInterface + */ + protected $languageManager; + + /** + * The current route match. + * + * @var \Drupal\Core\Routing\RouteMatchInterface + */ + protected $routeMatch; + + /** + * Creates a ComponentPluginTranslate object. + * + * @param \Drupal\Core\Language\LanguageManagerInterface $language_manager + * The language manager. + * @param \Drupal\Core\Routing\RouteMatchInterface $route_match + * The current route match. + */ + public function __construct(LanguageManagerInterface $language_manager, RouteMatchInterface $route_match) { + $this->languageManager = $language_manager; + $this->routeMatch = $route_match; + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + $events[LayoutBuilderEvents::SECTION_COMPONENT_BUILD_RENDER_ARRAY] = ['onBuildRender', 200]; + return $events; + } + + /** + * Translates the plugin configuration if needed. + * + * @param \Drupal\layout_builder\Event\SectionComponentBuildRenderArrayEvent $event + * The section component render event. + */ + public function onBuildRender(SectionComponentBuildRenderArrayEvent $event) { + if (!$this->languageManager->isMultilingual()) { + return; + } + $plugin = $event->getPlugin(); + $contexts = $event->getContexts(); + $component = $event->getComponent(); + if (!$plugin instanceof ConfigurableInterface || !isset($contexts['layout_builder.entity'])) { + return; + } + + // @todo Change to 'entity' in https://www.drupal.org/node/3018782. + $entity = $contexts['layout_builder.entity']->getContextValue(); + $configuration = $plugin->getConfiguration(); + if ($event->inPreview()) { + $section_storage = $this->routeMatch->getParameter('section_storage'); + } + else { + $section_storage = $this->getSectionStorageForEntity($entity); + } + + if ($section_storage && static::isTranslation($section_storage)) { + if ($translated_plugin_configuration = $section_storage->getTranslatedComponentConfiguration($component->getUuid())) { + $translated_plugin_configuration = array_replace_recursive($configuration, $translated_plugin_configuration); + $plugin->setConfiguration($translated_plugin_configuration); + } + } + } + +} diff --git a/core/modules/layout_builder/src/Field/LayoutTranslationItemList.php b/core/modules/layout_builder/src/Field/LayoutTranslationItemList.php new file mode 100644 index 0000000000..66830ae13b --- /dev/null +++ b/core/modules/layout_builder/src/Field/LayoutTranslationItemList.php @@ -0,0 +1,21 @@ +<?php + +namespace Drupal\layout_builder\Field; + +use Drupal\Core\Access\AccessResult; +use Drupal\Core\Field\FieldItemList; +use Drupal\Core\Session\AccountInterface; + +class LayoutTranslationItemList extends FieldItemList { + + /** + * Overrides \Drupal\Core\Field\FieldItemListInterface::defaultAccess(). + * + * @ingroup layout_builder_access + */ + public function defaultAccess($operation = 'view', AccountInterface $account = NULL) { + // @todo Allow access in https://www.drupal.org/node/2942975. + return AccessResult::forbidden(); + } + +} diff --git a/core/modules/layout_builder/src/Form/BlockContentInlineBlockTranslateForm.php b/core/modules/layout_builder/src/Form/BlockContentInlineBlockTranslateForm.php new file mode 100644 index 0000000000..79165e95f3 --- /dev/null +++ b/core/modules/layout_builder/src/Form/BlockContentInlineBlockTranslateForm.php @@ -0,0 +1,144 @@ +<?php + +namespace Drupal\layout_builder\Form; + +use Drupal\block_content\BlockContentForm; +use Drupal\Component\Datetime\TimeInterface; +use Drupal\Core\Entity\EntityRepositoryInterface; +use Drupal\Core\Entity\EntityTypeBundleInfoInterface; +use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Routing\RouteMatchInterface; +use Drupal\layout_builder\LayoutTempstoreRepositoryInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Provides a form class for translating inline blocks in the Layout Builder. + * + * @internal + * Form classes are internal. + */ +class BlockContentInlineBlockTranslateForm extends BlockContentForm { + + /** + * The layout tempstore repository. + * + * @var \Drupal\layout_builder\LayoutTempstoreRepositoryInterface + */ + protected $layoutTempstoreRepository; + + /** + * The component UUID. + * + * @var string + */ + protected $uuid; + + /** + * The section delta. + * + * @var int + */ + protected $delta; + + /** + * The section storage. + * + * @var \Drupal\layout_builder\TranslatableSectionStorageInterface + */ + protected $sectionStorage; + + /** + * {@inheritdoc} + */ + public function __construct(EntityRepositoryInterface $entity_repository, EntityTypeBundleInfoInterface $entity_type_bundle_info = NULL, TimeInterface $time = NULL, RouteMatchInterface $route_match = NULL, LayoutTempstoreRepositoryInterface $tempstore = NULL) { + parent::__construct($entity_repository, $entity_type_bundle_info, $time); + $this->routeMatch = $route_match; + $this->layoutTempstoreRepository = $tempstore; + $this->uuid = $route_match->getParameter('uuid'); + $this->delta = $route_match->getParameter('delta'); + $this->sectionStorage = $route_match->getParameter('section_storage'); + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('entity.repository'), + $container->get('entity_type.bundle.info'), + $container->get('datetime.time'), + $container->get('current_route_match'), + $container->get('layout_builder.tempstore_repository') + ); + } + + /** + * {@inheritdoc} + */ + public function getEntityFromRouteMatch(RouteMatchInterface $route_match, $entity_type_id) { + /** @var \Drupal\layout_builder\TranslatableSectionStorageInterface $section_storage */ + $translated_configuration = $this->sectionStorage->getTranslatedComponentConfiguration($this->uuid); + $langcode = $this->sectionStorage->getTranslationLanguage()->getId(); + + if (!empty($translated_configuration)) { + if (!empty($translated_configuration['block_serialized'])) { + return unserialize($translated_configuration['block_serialized']); + } + elseif (!empty($translated_configuration['block_revision_id'])) { + /** @var \Drupal\block_content\BlockContentInterface $entity */ + $entity = $this->entityTypeManager->getStorage('block_content')->loadRevision($translated_configuration['block_revision_id']); + $entity = $this->entityRepository->getActive('block_content', $entity->id()); + if ($entity->hasTranslation($langcode)) { + return $entity->getTranslation($langcode); + } + } + } + $configuration = $this->sectionStorage->getSection($this->delta)->getComponent($this->uuid)->getPlugin()->getConfiguration(); + if (!empty($configuration['block_revision_id'])) { + /** @var \Drupal\block_content\BlockContentInterface $entity */ + $entity = $this->entityTypeManager->getStorage('block_content')->loadRevision($configuration['block_revision_id']); + $entity = $this->entityRepository->getActive('block_content', $entity->id()); + if ($entity->hasTranslation($langcode)) { + return $entity->getTranslation($langcode); + } + else { + $translation = $entity->addTranslation($langcode, $entity->toArray()); + if (!empty($translated_configuration['label'])) { + $translation->setInfo($translated_configuration['label']); + } + return $translation; + } + } + else { + throw new \LogicException("InlineBlockTranslationForm should never be invoked without an available block_content entity"); + } + } + + /** + * {@inheritdoc} + */ + public function save(array $form, FormStateInterface $form_state) { + $entity = $this->entity; + $translated_configuration = $this->sectionStorage->getTranslatedComponentConfiguration($this->uuid); + $translated_configuration['block_serialized'] = serialize($entity); + $translated_configuration['label'] = $entity->label(); + $this->sectionStorage->setTranslatedComponentConfiguration($this->uuid, $translated_configuration); + $this->layoutTempstoreRepository->set($this->sectionStorage); + + return $entity->isNew() ? SAVED_NEW : SAVED_UPDATED; + } + + /** + * {@inheritdoc} + */ + public function form(array $form, FormStateInterface $form_state) { + $form = parent::form($form, $form_state); + // The language of the translation cannot be changed. + $form['langcode']['#access'] = FALSE; + $form['revision_log']['#access'] = FALSE; + // Creating new revisions is based on the entity with the layout. + $form['revision']['#access'] = FALSE; + return $form; + } + +} diff --git a/core/modules/layout_builder/src/Form/OverridesEntityForm.php b/core/modules/layout_builder/src/Form/OverridesEntityForm.php index 789778fc8f..f8facf3ecc 100644 --- a/core/modules/layout_builder/src/Form/OverridesEntityForm.php +++ b/core/modules/layout_builder/src/Form/OverridesEntityForm.php @@ -9,6 +9,7 @@ use Drupal\Core\Entity\EntityRepositoryInterface; use Drupal\Core\Entity\EntityTypeBundleInfoInterface; use Drupal\Core\Form\FormStateInterface; +use Drupal\layout_builder\LayoutEntityHelperTrait; use Drupal\layout_builder\LayoutTempstoreRepositoryInterface; use Drupal\layout_builder\OverridesSectionStorageInterface; use Drupal\layout_builder\Plugin\SectionStorage\OverridesSectionStorage; @@ -24,6 +25,7 @@ class OverridesEntityForm extends ContentEntityForm { use PreviewToggleTrait; + use LayoutEntityHelperTrait; /** * Layout tempstore repository. @@ -82,7 +84,10 @@ protected function init(FormStateInterface $form_state) { parent::init($form_state); $form_display = EntityFormDisplay::collectRenderDisplay($this->entity, $this->getOperation(), FALSE); - $form_display->setComponent(OverridesSectionStorage::FIELD_NAME, [ + $field_name = static::isTranslation($this->sectionStorage) ? + OverridesSectionStorage::TRANSLATED_CONFIGURATION_FIELD_NAME : + OverridesSectionStorage::FIELD_NAME; + $form_display->setComponent($field_name, [ 'type' => 'layout_builder_widget', 'weight' => -10, 'settings' => [], @@ -103,6 +108,7 @@ public function buildForm(array $form, FormStateInterface $form_state, SectionSt // restricts all access to the field, explicitly allow access here until // https://www.drupal.org/node/2942975 is resolved. $form[OverridesSectionStorage::FIELD_NAME]['#access'] = TRUE; + $form[OverridesSectionStorage::TRANSLATED_CONFIGURATION_FIELD_NAME]['#access'] = TRUE; $form['layout_builder_message'] = $this->buildMessage($section_storage->getContextValue('entity'), $section_storage); return $form; @@ -201,14 +207,16 @@ protected function actions(array $form, FormStateInterface $form_state) { // Discard is not dependent on form input. '#limit_validation_errors' => [], ]; - // @todo This button should be conditionally displayed, see - // https://www.drupal.org/node/2917777. - $actions['revert'] = [ - '#type' => 'submit', - '#value' => $this->t('Revert to defaults'), - '#submit' => ['::redirectOnSubmit'], - '#redirect' => 'revert', - ]; + if (!static::isTranslation($this->sectionStorage)) { + // @todo This button should be conditionally displayed, see + // https://www.drupal.org/node/2917777. + $actions['revert'] = [ + '#type' => 'submit', + '#value' => $this->t('Revert to defaults'), + '#submit' => ['::redirectOnSubmit'], + '#redirect' => 'revert', + ]; + } $actions['preview_toggle'] = $this->buildContentPreviewToggle(); return $actions; } diff --git a/core/modules/layout_builder/src/Form/TranslateBlockForm.php b/core/modules/layout_builder/src/Form/TranslateBlockForm.php new file mode 100644 index 0000000000..9812a40e68 --- /dev/null +++ b/core/modules/layout_builder/src/Form/TranslateBlockForm.php @@ -0,0 +1,218 @@ +<?php + +namespace Drupal\layout_builder\Form; + +use Drupal\Component\Utility\Html; +use Drupal\config_translation\Form\ConfigTranslationFormBase; +use Drupal\Core\Ajax\AjaxFormHelperTrait; +use Drupal\Core\Config\TypedConfigManagerInterface; +use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\Form\FormBase; +use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Language\LanguageInterface; +use Drupal\Core\TypedData\TraversableTypedDataInterface; +use Drupal\layout_builder\Controller\LayoutRebuildTrait; +use Drupal\layout_builder\LayoutTempstoreRepositoryInterface; +use Drupal\layout_builder\TranslatableSectionStorageInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Provides a form to translate a block plugin in the Layout Builder. + * + * @internal + * Form classes are internal. + */ +class TranslateBlockForm extends FormBase { + + use AjaxFormHelperTrait; + use LayoutRebuildTrait; + + /** + * The section storage. + * + * @var \Drupal\layout_builder\TranslatableSectionStorageInterface + */ + protected $sectionStorage; + + /** + * The UUID of the component. + * + * @var string + */ + protected $uuid; + + /** + * The layout tempstore repository. + * + * @var \Drupal\layout_builder\LayoutTempstoreRepositoryInterface + */ + protected $layoutTempstoreRepository; + + /** + * The module handler. + * + * @var \Drupal\Core\Extension\ModuleHandlerInterface + */ + protected $moduleHandler; + + /** + * @var \Drupal\Core\Config\TypedConfigManagerInterface + */ + protected $typedConfigManager; + + /** + * Constructs a new TranslateBlockForm. + */ + public function __construct(LayoutTempstoreRepositoryInterface $layout_tempstore_repository, ModuleHandlerInterface $module_handler, TypedConfigManagerInterface $typed_config_manager) { + $this->layoutTempstoreRepository = $layout_tempstore_repository; + $this->moduleHandler = $module_handler; + $this->typedConfigManager = $typed_config_manager; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('layout_builder.tempstore_repository'), + $container->get('module_handler'), + $container->get('config.typed') + ); + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'layout_builder_block_translation'; + } + + /** + * Builds the block translation form. + * + * @param array $form + * An associative array containing the structure of the form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * @param \Drupal\layout_builder\TranslatableSectionStorageInterface $section_storage + * The section storage being configured. + * @param int $delta + * The delta of the section. + * @param string $region + * The region of the block. + * @param string $uuid + * The UUID of the block being updated. + * + * @return array + * The form array. + */ + public function buildForm(array $form, FormStateInterface $form_state, TranslatableSectionStorageInterface $section_storage = NULL, $delta = NULL, $region = NULL, $uuid = NULL) { + $component = $section_storage->getSection($delta)->getComponent($uuid); + + $this->sectionStorage = $section_storage; + $this->uuid = $component->getUuid(); + + $configuration = $component->getPlugin()->getConfiguration(); + $type_definition = $this->typedConfigManager->getDefinition('block.settings.' . $component->getPlugin()->getPluginId()); + /** @var \Drupal\Core\TypedData\DataDefinitionInterface $definition */ + $definition = new $type_definition['definition_class']($type_definition); + $definition->setClass($type_definition['class']); + + /** @var \Drupal\Core\Config\Schema\Mapping $typed_data */ + $typed_data = $type_definition['class']::createInstance($definition); + $typed_data->setValue($configuration); + $translated_config = $this->sectionStorage->getTranslatedComponentConfiguration($this->uuid); + foreach (array_keys($configuration) as $key) { + if (!isset($translated_config[$key])) { + $translated_config[$key] = NULL; + } + } + + $form['translation'] = $this->createTranslationElement($section_storage->getSourceLanguage(), $section_storage->getTranslationLanguage(), $typed_data, $translated_config); + $form['submit'] = [ + '#type' => 'submit', + '#value' => $this->t('Translate'), + ]; + + if ($this->isAjax()) { + $form['submit']['#ajax']['callback'] = '::ajaxSubmit'; + // @todo static::ajaxSubmit() requires data-drupal-selector to be the same + // between the various Ajax requests. A bug in + // \Drupal\Core\Form\FormBuilder prevents that from happening unless + // $form['#id'] is also the same. Normally, #id is set to a unique HTML + // ID via Html::getUniqueId(), but here we bypass that in order to work + // around the data-drupal-selector bug. This is okay so long as we + // assume that this form only ever occurs once on a page. Remove this + // workaround in https://www.drupal.org/node/2897377. + $form['#id'] = Html::getId($form_state->getBuildInfo()['form_id']); + } + return $form; + } + + /** + * Creates translation element. + * + * @param \Drupal\Core\Language\LanguageInterface $source_language + * The source language. + * @param \Drupal\Core\Language\LanguageInterface $translation_language + * The translation language. + * @param \Drupal\Core\TypedData\TraversableTypedDataInterface $typed_data + * The typed data of the configuration settings. + * @param array $translated_configuration + * The translated configuration. + * + * @return array + * The translation element render array. + */ + protected function createTranslationElement(LanguageInterface $source_language, LanguageInterface $translation_language, TraversableTypedDataInterface $typed_data, array $translated_configuration) { + if ($this->moduleHandler->moduleExists('config_translation')) { + // If config_translation is installed let it handle creating complex + // schema. + $form_element = ConfigTranslationFormBase::createFormElement($typed_data); + $element_build = $form_element->getTranslationBuild($source_language, $translation_language, $typed_data->getValue(), $translated_configuration, []); + } + else { + /** @var \Drupal\Core\TypedData\TypedDataInterface $typed_datum */ + foreach ($typed_data as $key => $typed_datum) { + $definition = $typed_datum->getDataDefinition(); + $data_type = $definition->getDataType(); + + // Provide translation of top level label and text items. + if ($data_type === 'label' || $data_type === 'text') { + $element_build[$key]['source'] = [ + '#type' => 'item', + '#title' => $this->t($definition->getLabel()), + '#markup' => $typed_datum->getValue() ?: '(' . $this->t('Empty') . ')', + '#parents' => ['source', $key], + ]; + $element_build[$key]['translation'] = [ + '#type' => $data_type === 'label' ? 'textfield' : 'textarea', + '#title' => $this->t($definition->getLabel()), + '#default_value' => isset($translated_configuration[$key]) ? $translated_configuration[$key] : '', + '#parents' => ['translation', $key], + ]; + } + } + } + $element_build['#tree'] = TRUE; + return $element_build; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $section_storage = $this->sectionStorage; + $section_storage->setTranslatedComponentConfiguration($this->uuid, $form_state->getValue('translation')); + $this->layoutTempstoreRepository->set($this->sectionStorage); + $form_state->setRedirectUrl($this->sectionStorage->getLayoutBuilderUrl()); + } + + /** + * {@inheritdoc} + */ + protected function successfulAjaxSubmit(array $form, FormStateInterface $form_state) { + return $this->rebuildAndClose($this->sectionStorage); + } + +} diff --git a/core/modules/layout_builder/src/InlineBlockEntityOperations.php b/core/modules/layout_builder/src/InlineBlockEntityOperations.php index 5b11bbe674..183436de15 100644 --- a/core/modules/layout_builder/src/InlineBlockEntityOperations.php +++ b/core/modules/layout_builder/src/InlineBlockEntityOperations.php @@ -2,6 +2,7 @@ namespace Drupal\layout_builder; +use Drupal\Core\Block\BlockManagerInterface; use Drupal\Core\DependencyInjection\ContainerInjectionInterface; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; @@ -41,6 +42,13 @@ class InlineBlockEntityOperations implements ContainerInjectionInterface { */ protected $entityTypeManager; + /** + * The block plugin manager. + * + * @var \Drupal\Core\Block\BlockManagerInterface + */ + protected $blockManager; + /** * Constructs a new EntityOperations object. * @@ -50,12 +58,19 @@ class InlineBlockEntityOperations implements ContainerInjectionInterface { * Inline block usage tracking service. * @param \Drupal\layout_builder\SectionStorage\SectionStorageManagerInterface $section_storage_manager * The section storage manager. + * @param \Drupal\Core\Block\BlockManagerInterface|null $block_manager + * (optional) The block manager; */ - public function __construct(EntityTypeManagerInterface $entityTypeManager, InlineBlockUsageInterface $usage, SectionStorageManagerInterface $section_storage_manager) { + public function __construct(EntityTypeManagerInterface $entityTypeManager, InlineBlockUsageInterface $usage, SectionStorageManagerInterface $section_storage_manager, BlockManagerInterface $block_manager = NULL) { $this->entityTypeManager = $entityTypeManager; $this->blockContentStorage = $entityTypeManager->getStorage('block_content'); $this->usage = $usage; $this->sectionStorageManager = $section_storage_manager; + if ($block_manager === NULL) { + @trigger_error('The plugin.manager.block service must be passed to \Drupal\layout_builder\InlineBlockEntityOperations::__construct(). It was added in Drupal 9.1.0 and will be required before Drupal 10.0.0.', E_USER_DEPRECATED); + $block_manager = \Drupal::service('plugin.manager.block'); + } + $this->blockManager = $block_manager; } /** @@ -65,7 +80,8 @@ public static function create(ContainerInterface $container) { return new static( $container->get('entity_type.manager'), $container->get('inline_block.usage'), - $container->get('plugin.manager.layout_builder.section_storage') + $container->get('plugin.manager.layout_builder.section_storage'), + $container->get('plugin.manager.block') ); } @@ -161,8 +177,17 @@ public function handlePreSave(EntityInterface $entity) { // revisions, when a block is modified, it must always result in the // creation of a new block revision. $new_revision = $entity instanceof RevisionableInterface; + $section_storage = $this->getSectionStorageForEntity($entity); foreach ($this->getInlineBlockComponents($sections) as $component) { - $this->saveInlineBlockComponent($entity, $component, $new_revision, $duplicate_blocks); + if (static::isTranslation($section_storage)) { + $translated_component_configuration = $section_storage->getTranslatedComponentConfiguration($component->getUuid()); + if (isset($translated_component_configuration['block_serialized'])) { + $this->saveTranslatedInlineBlock($entity, $component->getUuid(), $translated_component_configuration, $new_revision); + } + } + else { + $this->saveInlineBlockComponent($entity, $component, $new_revision, $duplicate_blocks); + } } } $this->removeUnusedForEntityOnSave($entity); @@ -254,4 +279,37 @@ protected function saveInlineBlockComponent(EntityInterface $entity, SectionComp $component->setConfiguration($post_save_configuration); } + /** + * Saves a translated inline block. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity with the layout. + * @param string $component_uuid + * The component UUID. + * @param array $translated_component_configuration + * The translated component configuration. + * @param bool $new_revision + * Whether a new revision of the block should be created. + */ + protected function saveTranslatedInlineBlock(EntityInterface $entity, $component_uuid, array $translated_component_configuration, $new_revision) { + /** @var \Drupal\block_content\BlockContentInterface $block */ + $block = unserialize($translated_component_configuration['block_serialized']); + // Create a InlineBlock plugin from the translated configuration in order to + // save the block. + /** @var \Drupal\layout_builder\Plugin\Block\InlineBlock $plugin */ + $plugin = $this->blockManager->createInstance('inline_block:' . $block->bundle(), $translated_component_configuration); + $plugin->saveBlockContent($new_revision); + // Remove serialized block after the block has been saved. + unset($translated_component_configuration['block_serialized']); + + // Update the block_revision_id in the translated configuration which may + // have changed after saving the block. + $configuration = $plugin->getConfiguration(); + $translated_component_configuration['block_revision_id'] = $configuration['block_revision_id']; + + /** @var \Drupal\layout_builder\TranslatableSectionStorageInterface $section_storage */ + $section_storage = $this->getSectionStorageForEntity($entity); + $section_storage->setTranslatedComponentConfiguration($component_uuid, $translated_component_configuration); + } + } diff --git a/core/modules/layout_builder/src/LayoutEntityHelperTrait.php b/core/modules/layout_builder/src/LayoutEntityHelperTrait.php index 1abf322178..4ec2e83513 100644 --- a/core/modules/layout_builder/src/LayoutEntityHelperTrait.php +++ b/core/modules/layout_builder/src/LayoutEntityHelperTrait.php @@ -155,4 +155,17 @@ private function sectionStorageManager() { return $this->sectionStorageManager ?: \Drupal::service('plugin.manager.layout_builder.section_storage'); } + /** + * Determines if the sections is for a translation. + * + * @param \Drupal\layout_builder\SectionStorageInterface $section_storage + * The section storage. + * + * @return bool + * TRUE if the section storage is for translation otherwise false. + */ + protected static function isTranslation(SectionStorageInterface $section_storage) { + return $section_storage instanceof TranslatableSectionStorageInterface && !$section_storage->isDefaultTranslation(); + } + } diff --git a/core/modules/layout_builder/src/Plugin/Block/InlineBlock.php b/core/modules/layout_builder/src/Plugin/Block/InlineBlock.php index a13bb8392d..d0c1609c82 100644 --- a/core/modules/layout_builder/src/Plugin/Block/InlineBlock.php +++ b/core/modules/layout_builder/src/Plugin/Block/InlineBlock.php @@ -9,6 +9,7 @@ use Drupal\Core\Block\BlockBase; use Drupal\Core\Entity\Entity\EntityFormDisplay; use Drupal\Core\Entity\EntityDisplayRepositoryInterface; +use Drupal\Core\Entity\EntityRepositoryInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Form\SubformStateInterface; @@ -61,6 +62,13 @@ class InlineBlock extends BlockBase implements ContainerFactoryPluginInterface, */ protected $isNew = TRUE; + /** + * The entity repository. + * + * @var \Drupal\Core\Entity\EntityRepositoryInterface + */ + protected $entityRepository; + /** * The current user. * @@ -83,8 +91,10 @@ class InlineBlock extends BlockBase implements ContainerFactoryPluginInterface, * The entity display repository. * @param \Drupal\Core\Session\AccountInterface $current_user * The current user. + * @param \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository + * The entity repository. */ - public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, EntityDisplayRepositoryInterface $entity_display_repository, AccountInterface $current_user) { + public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, EntityDisplayRepositoryInterface $entity_display_repository, AccountInterface $current_user, EntityRepositoryInterface $entity_repository) { parent::__construct($configuration, $plugin_id, $plugin_definition); $this->entityTypeManager = $entity_type_manager; @@ -93,6 +103,7 @@ public function __construct(array $configuration, $plugin_id, $plugin_definition if (!empty($this->configuration['block_revision_id']) || !empty($this->configuration['block_serialized'])) { $this->isNew = FALSE; } + $this->entityRepository = $entity_repository; } /** @@ -105,7 +116,8 @@ public static function create(ContainerInterface $container, array $configuratio $plugin_definition, $container->get('entity_type.manager'), $container->get('entity_display.repository'), - $container->get('current_user') + $container->get('current_user'), + $container->get('entity.repository') ); } @@ -125,6 +137,10 @@ public function defaultConfiguration() { */ public function blockForm($form, FormStateInterface $form_state) { $block = $this->getEntity(); + if (!$this->isNew && !$block->isNew() && empty($this->configuration['block_serialized'])) { + // Get the active block for editing purposes. + $block = $this->entityRepository->getActive('block_content', $block->id()); + } // Add the entity form display in a process callback so that #parents can // be successfully propagated to field widgets. @@ -165,6 +181,9 @@ public static function processBlockForm(array $element, FormStateInterface $form EntityFormDisplay::collectRenderDisplay($block, 'edit')->buildForm($block, $element, $form_state); $element['revision_log']['#access'] = FALSE; $element['info']['#access'] = FALSE; + if (isset($element['langcode'])) { + $element['langcode']['#access'] = FALSE; + } return $element; } diff --git a/core/modules/layout_builder/src/Plugin/DataType/LayoutTranslationData.php b/core/modules/layout_builder/src/Plugin/DataType/LayoutTranslationData.php new file mode 100644 index 0000000000..64aeae90e6 --- /dev/null +++ b/core/modules/layout_builder/src/Plugin/DataType/LayoutTranslationData.php @@ -0,0 +1,25 @@ +<?php + +namespace Drupal\layout_builder\Plugin\DataType; + +use Drupal\Core\TypedData\TypedData; + +/** + * Provides a data type wrapping \Drupal\layout_builder\Section. + * + * @DataType( + * id = "layout_translation", + * label = @Translation("Layout translation"), + * description = @Translation("A layout translation"), + * ) + */ +class LayoutTranslationData extends TypedData { + + /** + * The layout translation. + * + * @var array + */ + protected $value; + +} diff --git a/core/modules/layout_builder/src/Plugin/Field/FieldType/LayoutTranslationItem.php b/core/modules/layout_builder/src/Plugin/Field/FieldType/LayoutTranslationItem.php new file mode 100644 index 0000000000..2d178ed4a7 --- /dev/null +++ b/core/modules/layout_builder/src/Plugin/Field/FieldType/LayoutTranslationItem.php @@ -0,0 +1,54 @@ +<?php + +namespace Drupal\layout_builder\Plugin\Field\FieldType; + +use Drupal\Core\Field\FieldItemBase; +use Drupal\Core\Field\FieldStorageDefinitionInterface; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\Core\TypedData\DataDefinition; + +/** + * Plugin implementation of the 'layout_section' field type. + * + * @internal + * + * @FieldType( + * id = "layout_translation", + * label = @Translation("Layout Translation"), + * description = @Translation("Layout Translation"), + * no_ui = TRUE, + * cardinality = 1, + * list_class = "\Drupal\layout_builder\Field\LayoutTranslationItemList", + * ) + */ +class LayoutTranslationItem extends FieldItemBase { + + /** + * {@inheritdoc} + */ + public static function propertyDefinitions(FieldStorageDefinitionInterface $field_definition) { + $properties['value'] = DataDefinition::create('layout_translation') + ->setLabel(new TranslatableMarkup('Layout Translation')) + ->setRequired(FALSE); + + return $properties; + } + + /** + * {@inheritdoc} + */ + public static function schema(FieldStorageDefinitionInterface $field_definition) { + $schema = [ + 'columns' => [ + 'value' => [ + 'type' => 'blob', + 'size' => 'normal', + 'serialize' => TRUE, + ], + ], + ]; + + return $schema; + } + +} diff --git a/core/modules/layout_builder/src/Plugin/Field/FieldWidget/LayoutBuilderWidget.php b/core/modules/layout_builder/src/Plugin/Field/FieldWidget/LayoutBuilderWidget.php index 0195065ff5..1e5251689e 100644 --- a/core/modules/layout_builder/src/Plugin/Field/FieldWidget/LayoutBuilderWidget.php +++ b/core/modules/layout_builder/src/Plugin/Field/FieldWidget/LayoutBuilderWidget.php @@ -5,6 +5,8 @@ use Drupal\Core\Field\FieldItemListInterface; use Drupal\Core\Field\WidgetBase; use Drupal\Core\Form\FormStateInterface; +use Drupal\layout_builder\Plugin\SectionStorage\OverridesSectionStorage; +use Drupal\layout_builder\TranslatableSectionStorageInterface; /** * A widget to display the layout form. @@ -15,6 +17,7 @@ * description = @Translation("A field widget for Layout Builder."), * field_types = { * "layout_section", + * "layout_translation" * }, * multiple_values = TRUE, * ) @@ -60,7 +63,19 @@ public function extractFormValues(FieldItemListInterface $items, array $form, Fo return; } - $items->setValue($this->getSectionStorage($form_state)->getSections()); + $field_name = $this->fieldDefinition->getName(); + $section_storage = $this->getSectionStorage($form_state); + if ($field_name === OverridesSectionStorage::FIELD_NAME) { + $items->setValue($section_storage->getSections()); + } + elseif ($field_name === OverridesSectionStorage::TRANSLATED_CONFIGURATION_FIELD_NAME && $section_storage instanceof TranslatableSectionStorageInterface) { + // The translated configuration is stored in single value field because it + // stores configuration for components in all sections. + $items->set(0, $section_storage->getTranslatedConfiguration()); + } + else { + throw new \LogicException("Widget used with unexpected field, $field_name for section storage: " . $section_storage->getStorageType()); + } } /** diff --git a/core/modules/layout_builder/src/Plugin/SectionStorage/OverridesSectionStorage.php b/core/modules/layout_builder/src/Plugin/SectionStorage/OverridesSectionStorage.php index 7e17c88e7b..1eb7a5a9df 100644 --- a/core/modules/layout_builder/src/Plugin/SectionStorage/OverridesSectionStorage.php +++ b/core/modules/layout_builder/src/Plugin/SectionStorage/OverridesSectionStorage.php @@ -19,6 +19,7 @@ use Drupal\layout_builder\Entity\LayoutBuilderEntityViewDisplay; use Drupal\layout_builder\OverridesSectionStorageInterface; use Drupal\layout_builder\SectionStorage\SectionStorageManagerInterface; +use Drupal\layout_builder\TranslatableSectionStorageInterface; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\Routing\RouteCollection; @@ -46,15 +47,22 @@ * @internal * Plugin classes are internal. */ -class OverridesSectionStorage extends SectionStorageBase implements ContainerFactoryPluginInterface, OverridesSectionStorageInterface, SectionStorageLocalTaskProviderInterface { +class OverridesSectionStorage extends SectionStorageBase implements ContainerFactoryPluginInterface, OverridesSectionStorageInterface, TranslatableSectionStorageInterface, SectionStorageLocalTaskProviderInterface { /** - * The field name used by this storage. + * The field name for layout sections used by this storage. * * @var string */ const FIELD_NAME = 'layout_builder__layout'; + /** + * The field name for translated configuration used by this storage. + * + * @var string + */ + const TRANSLATED_CONFIGURATION_FIELD_NAME = 'layout_builder__translation'; + /** * The entity type manager. * @@ -195,7 +203,7 @@ public function deriveContextsFromRoute($value, $definition, $name, array $defau */ private function extractEntityFromRoute($value, array $defaults) { if (str_contains($value, '.')) { - [$entity_type_id, $entity_id] = explode('.', $value, 2); + [$entity_type_id, $entity_id] = explode('.', $value); } elseif (isset($defaults['entity_type_id']) && !empty($defaults[$defaults['entity_type_id']])) { $entity_type_id = $defaults['entity_type_id']; @@ -380,7 +388,12 @@ public function access($operation, AccountInterface $account = NULL, $return_as_ protected function handleTranslationAccess(AccessResult $result, $operation, AccountInterface $account) { $entity = $this->getEntity(); // Access is always denied on non-default translations. - return $result->andIf(AccessResult::allowedIf(!($entity instanceof TranslatableInterface && !$entity->isDefaultTranslation())))->addCacheableDependency($entity); + $field_config = $entity->getFieldDefinition(static::FIELD_NAME)->getConfig($entity->bundle()); + // Access is allow if one of the following conditions is true: + // 1. This is the default translation. + // 2. The entity is translatable and the layout is overridden and the layout + // field is not translatable. + return $result->andIf(AccessResult::allowedIf($this->isDefaultTranslation() || ($entity instanceof TranslatableInterface && $this->isOverridden() && !$field_config->isTranslatable())))->addCacheableDependency($entity)->addCacheableDependency($field_config); } /** @@ -403,4 +416,84 @@ public function isOverridden() { return !empty($this->getSections()); } + /** + * Indicates if the layout is translatable. + * + * @return bool + * TRUE if the layout is translatable, otherwise FALSE. + */ + protected function isTranslatable() { + $entity = $this->getEntity(); + if ($entity instanceof TranslatableInterface) { + return $entity->isTranslatable(); + } + return FALSE; + } + + /** + * {@inheritdoc} + */ + public function isDefaultTranslation() { + if ($this->isTranslatable()) { + /** @var \Drupal\Core\Entity\TranslatableInterface $entity */ + $entity = $this->getEntity(); + return $entity->isDefaultTranslation(); + } + return TRUE; + } + + /** + * {@inheritdoc} + */ + public function setTranslatedComponentConfiguration($uuid, array $configuration) { + if (!$this->getEntity()->get(OverridesSectionStorage::TRANSLATED_CONFIGURATION_FIELD_NAME)->isEmpty()) { + $translation_settings = $this->getEntity()->get(OverridesSectionStorage::TRANSLATED_CONFIGURATION_FIELD_NAME)->getValue()[0]; + } + $translation_settings['value']['components'][$uuid] = $configuration; + $this->getEntity()->set(OverridesSectionStorage::TRANSLATED_CONFIGURATION_FIELD_NAME, [$translation_settings]); + } + + /** + * {@inheritdoc} + */ + public function getTranslatedComponentConfiguration($uuid) { + if ($this->getEntity()->get(OverridesSectionStorage::TRANSLATED_CONFIGURATION_FIELD_NAME)->isEmpty()) { + return []; + } + $translation_settings = $this->getEntity()->get(OverridesSectionStorage::TRANSLATED_CONFIGURATION_FIELD_NAME)->getValue()[0]; + return isset($translation_settings['value']['components'][$uuid]) ? $translation_settings['value']['components'][$uuid] : []; + } + + /** + * {@inheritdoc} + */ + public function getTranslatedConfiguration() { + if ($this->getEntity()->get(OverridesSectionStorage::TRANSLATED_CONFIGURATION_FIELD_NAME)->isEmpty()) { + return []; + } + return $this->getEntity()->get(OverridesSectionStorage::TRANSLATED_CONFIGURATION_FIELD_NAME)->getValue()[0]; + } + + /** + * {@inheritdoc} + */ + public function getTranslationLanguage() { + if (!$this->isDefaultTranslation()) { + return $this->getEntity()->language(); + } + return NULL; + } + + /** + * {@inheritdoc} + */ + public function getSourceLanguage() { + if (!$this->isDefaultTranslation()) { + /** @var \Drupal\Core\Entity\TranslatableInterface $entity */ + $entity = $this->getEntity(); + return $entity->getUntranslated()->language(); + } + return NULL; + } + } diff --git a/core/modules/layout_builder/src/TranslatableSectionStorageInterface.php b/core/modules/layout_builder/src/TranslatableSectionStorageInterface.php new file mode 100644 index 0000000000..88aa16dfa1 --- /dev/null +++ b/core/modules/layout_builder/src/TranslatableSectionStorageInterface.php @@ -0,0 +1,65 @@ +<?php + +namespace Drupal\layout_builder; + +/** + * Defines an interface for translatable section overrides. + */ +interface TranslatableSectionStorageInterface extends SectionStorageInterface { + + /** + * Indicates if the layout is default translation layout. + * + * @return bool + * TRUE if the layout is the default translation layout, otherwise FALSE. + */ + public function isDefaultTranslation(); + + /** + * Sets the translated component configuration. + * + * @param string $uuid + * The component UUID. + * @param array $configuration + * The component's translated configuration. + */ + public function setTranslatedComponentConfiguration($uuid, array $configuration); + + /** + * Gets the translated component configuration. + * + * @param string $uuid + * The component UUID. + * + * @return array + * The component's translated configuration. + */ + public function getTranslatedComponentConfiguration($uuid); + + /** + * Gets the translated configuration for the layout. + * + * @return array + * The translated configuration for the layout. + */ + public function getTranslatedConfiguration(); + + /** + * Gets the language of the translation if any. + * + * @return \Drupal\Core\Language\LanguageInterface|null + * The translation language if the current layout is for a translation + * otherwise NULL. + */ + public function getTranslationLanguage(); + + /** + * Gets the source language of the translation if any. + * + * @return \Drupal\Core\Language\LanguageInterface|null + * The translation source language if the current layout is for a + * translation otherwise NULL. + */ + public function getSourceLanguage(); + +} diff --git a/core/modules/layout_builder/tests/src/Functional/Jsonapi/LayoutBuilderEntityViewDisplayTest.php b/core/modules/layout_builder/tests/src/Functional/Jsonapi/LayoutBuilderEntityViewDisplayTest.php index e5f91a4aca..0ff126e379 100644 --- a/core/modules/layout_builder/tests/src/Functional/Jsonapi/LayoutBuilderEntityViewDisplayTest.php +++ b/core/modules/layout_builder/tests/src/Functional/Jsonapi/LayoutBuilderEntityViewDisplayTest.php @@ -44,6 +44,7 @@ protected function getExpectedDocument() { $document = parent::getExpectedDocument(); array_unshift($document['data']['attributes']['dependencies']['module'], 'layout_builder'); $document['data']['attributes']['hidden'][OverridesSectionStorage::FIELD_NAME] = TRUE; + $document['data']['attributes']['hidden'][OverridesSectionStorage::TRANSLATED_CONFIGURATION_FIELD_NAME] = TRUE; $document['data']['attributes']['third_party_settings']['layout_builder'] = [ 'enabled' => TRUE, 'allow_custom' => TRUE, diff --git a/core/modules/layout_builder/tests/src/Functional/LayoutBuilderTranslationTest.php b/core/modules/layout_builder/tests/src/Functional/LayoutBuilderTranslationTest.php index 4b5999d903..70ed1ea546 100644 --- a/core/modules/layout_builder/tests/src/Functional/LayoutBuilderTranslationTest.php +++ b/core/modules/layout_builder/tests/src/Functional/LayoutBuilderTranslationTest.php @@ -2,17 +2,22 @@ namespace Drupal\Tests\layout_builder\Functional; +use Drupal\field\Entity\FieldConfig; +use Drupal\field\Entity\FieldStorageConfig; +use Drupal\layout_builder\Plugin\SectionStorage\OverridesSectionStorage; use Drupal\Tests\content_translation\Functional\ContentTranslationTestBase; use Drupal\Core\Entity\Entity\EntityViewDisplay; use Drupal\Core\Url; /** - * Tests that the Layout Builder works with translated content. + * Tests that the Layout Builder UI works with translated content. * * @group layout_builder */ class LayoutBuilderTranslationTest extends ContentTranslationTestBase { + use TranslationTestTrait; + /** * {@inheritdoc} */ @@ -46,32 +51,57 @@ protected function setUp(): void { } /** - * Tests that layout overrides work when created after a translation. + * Tests that the Layout Builder UI works with translated content. */ - public function testTranslationBeforeLayoutOverride() { + public function testLayoutPerTranslation() { $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); - $this->addEntityTranslation(); - - $entity_url = $this->entity->toUrl()->toString(); + $entity_url = $this->entity->toUrl('canonical')->toString(); $language = \Drupal::languageManager()->getLanguage($this->langcodes[2]); $translated_entity_url = $this->entity->toUrl('canonical', ['language' => $language])->toString(); + $layout_url = $entity_url . '/layout'; $translated_layout_url = $translated_entity_url . '/layout'; $this->drupalGet($entity_url); $assert_session->pageTextNotContains('The translated field value'); $assert_session->pageTextContains('The untranslated field value'); - $assert_session->linkExists('Layout'); $this->drupalGet($translated_entity_url); $assert_session->pageTextNotContains('The untranslated field value'); $assert_session->pageTextContains('The translated field value'); - $assert_session->linkNotExists('Layout'); + $this->drupalGet($layout_url); + $assert_session->pageTextNotContains('The translated field value'); + $assert_session->pageTextContains('The untranslated field value'); + + // If there is not a layout override the layout translation is not + // accessible. + $this->drupalGet($translated_layout_url); + $assert_session->pageTextContains('Access denied'); + + // Ensure that the tempstore varies per-translation. + $this->drupalGet($layout_url); + $assert_session->pageTextNotContains('The translated field value'); + $assert_session->pageTextContains('The untranslated field value'); + + // Adjust the layout of the original entity. + $assert_session->linkExists('Add block'); + $this->clickLink('Add block'); + $assert_session->linkExists('Powered by Drupal'); + $this->clickLink('Powered by Drupal'); + $page->pressButton('Add block'); + + $assert_session->pageTextContains('Powered by Drupal'); + + // Confirm the tempstore for the translated layout is not affected. $this->drupalGet($translated_layout_url); $assert_session->pageTextContains('Access denied'); - $this->addLayoutOverride(); + $this->drupalGet($layout_url); + $assert_session->pageTextContains('Powered by Drupal'); + $assert_session->buttonExists('Save layout'); + $page->pressButton('Save layout'); $this->drupalGet($entity_url); $assert_session->pageTextNotContains('The translated field value'); @@ -83,43 +113,121 @@ public function testTranslationBeforeLayoutOverride() { $assert_session->pageTextNotContains('The untranslated field value'); $assert_session->pageTextContains('The translated field value'); $assert_session->pageTextContains('Powered by Drupal'); + + // Confirm that layout translation page is accessible once the untranslated + // entity has a override. + $this->drupalGet($translated_layout_url); + $assert_session->pageTextNotContains('Access denied'); + $assert_session->pageTextNotContains('The untranslated field value'); + $assert_session->pageTextContains('The translated field value'); + $assert_session->pageTextContains('Powered by Drupal'); + $assert_session->buttonExists('Save layout'); + + $this->assertNonTranslationActionsRemoved(); + } /** - * Tests that layout overrides work when created before a translation. + * Tests that access is denied to a layout translation if there is override. */ - public function testLayoutOverrideBeforeTranslation() { + public function testLayoutTranslationNoOverride() { $assert_session = $this->assertSession(); - $entity_url = $this->entity->toUrl()->toString(); + $entity_url = $this->entity->toUrl('canonical')->toString(); $language = \Drupal::languageManager()->getLanguage($this->langcodes[2]); - $this->addLayoutOverride(); + $translated_entity_url = $this->entity->toUrl('canonical', ['language' => $language])->toString(); + $translated_layout_url = $translated_entity_url . '/layout'; $this->drupalGet($entity_url); $assert_session->pageTextNotContains('The translated field value'); $assert_session->pageTextContains('The untranslated field value'); - $assert_session->pageTextContains('Powered by Drupal'); - $assert_session->linkExists('Layout'); + $this->drupalGet($translated_entity_url); + $assert_session->pageTextNotContains('The untranslated field value'); + $assert_session->pageTextContains('The translated field value'); + + // If there is not a layout override the layout translation is not + // accessible. + $this->drupalGet($translated_layout_url); + $assert_session->pageTextContains('Access denied'); + } - $this->addEntityTranslation(); + /** + * Tests access to layout translation if the layout field is translatable. + */ + public function testTranslatableLayoutField() { + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + + $field_storage = FieldStorageConfig::loadByName('entity_test_mul', OverridesSectionStorage::FIELD_NAME); + $this->assertNotEmpty($field_storage); + $field_storage->setTranslatable(TRUE); + $this->assertNotEmpty($field_storage->save()); + $field_config = FieldConfig::loadByName('entity_test_mul', 'entity_test_mul', OverridesSectionStorage::FIELD_NAME); + $this->assertNotEmpty($field_config); + $field_config->setTranslatable(TRUE); + $this->assertNotEmpty($field_config->save()); + + $entity_url = $this->entity->toUrl('canonical')->toString(); + $layout_url = $entity_url . '/layout'; + $language = \Drupal::languageManager()->getLanguage($this->langcodes[2]); $translated_entity_url = $this->entity->toUrl('canonical', ['language' => $language])->toString(); $translated_layout_url = $translated_entity_url . '/layout'; $this->drupalGet($entity_url); $assert_session->pageTextNotContains('The translated field value'); $assert_session->pageTextContains('The untranslated field value'); - $assert_session->pageTextContains('Powered by Drupal'); - $assert_session->linkExists('Layout'); $this->drupalGet($translated_entity_url); $assert_session->pageTextNotContains('The untranslated field value'); $assert_session->pageTextContains('The translated field value'); + + // If there is not a layout override the layout translation is not + // accessible. + $this->drupalGet($translated_layout_url); + $assert_session->pageTextContains('Access denied'); + + // Ensure that the tempstore varies per-translation. + $this->drupalGet($layout_url); + $assert_session->pageTextNotContains('The translated field value'); + $assert_session->pageTextContains('The untranslated field value'); + + // Adjust the layout of the original entity. + $assert_session->linkExists('Add block'); + $this->clickLink('Add block'); + $assert_session->linkExists('Powered by Drupal'); + $this->clickLink('Powered by Drupal'); + $page->pressButton('Add block'); + $assert_session->pageTextContains('Powered by Drupal'); - $assert_session->linkNotExists('Layout'); + // Confirm the tempstore for the translated layout is not affected. $this->drupalGet($translated_layout_url); $assert_session->pageTextContains('Access denied'); + + $this->drupalGet($layout_url); + $assert_session->pageTextContains('Powered by Drupal'); + $assert_session->buttonExists('Save layout'); + $page->pressButton('Save layout'); + + $this->drupalGet($entity_url); + $assert_session->pageTextContains('Powered by Drupal'); + + // Confirm the translation layout is still not allowed. + $this->drupalGet($translated_layout_url); + + $assert_session->pageTextContains('Access denied'); + + // Update the layout field to be not translatable. + $field_config = FieldConfig::loadByName('entity_test_mul', 'entity_test_mul', OverridesSectionStorage::FIELD_NAME); + $this->assertNotEmpty($field_config); + $field_config->setTranslatable(FALSE); + $this->assertNotEmpty($field_config->save()); + + // Confirm the translation layout is still not allowed. + $this->drupalGet($translated_layout_url); + $assert_session->pageTextNotContains('Access denied'); + $assert_session->buttonExists('Save layout'); } /** @@ -152,6 +260,16 @@ protected function setUpEntities() { ->getStorage($this->entityTypeId); $storage->resetCache([$id]); $this->entity = $storage->load($id); + + // Create a translation. + $this->drupalLogin($this->translator); + $add_translation_url = Url::fromRoute("entity.$this->entityTypeId.content_translation_add", [ + $this->entityTypeId => $this->entity->id(), + 'source' => $this->langcodes[0], + 'target' => $this->langcodes[2], + ]); + $this->drupalGet($add_translation_url); + $this->submitForm(["{$this->fieldName}[0][value]" => 'The translated field value'], 'Save'); } /** @@ -170,44 +288,4 @@ protected function setUpViewDisplay() { ->save(); } - /** - * Adds an entity translation. - */ - protected function addEntityTranslation() { - $user = $this->loggedInUser; - $this->drupalLogin($this->translator); - $add_translation_url = Url::fromRoute("entity.$this->entityTypeId.content_translation_add", [ - $this->entityTypeId => $this->entity->id(), - 'source' => $this->langcodes[0], - 'target' => $this->langcodes[2], - ]); - $this->drupalGet($add_translation_url); - $this->submitForm(["{$this->fieldName}[0][value]" => 'The translated field value'], 'Save'); - $this->drupalLogin($user); - } - - /** - * Adds a layout override. - */ - protected function addLayoutOverride() { - $assert_session = $this->assertSession(); - $page = $this->getSession()->getPage(); - $entity_url = $this->entity->toUrl()->toString(); - $layout_url = $entity_url . '/layout'; - $this->drupalGet($layout_url); - $assert_session->pageTextNotContains('The translated field value'); - $assert_session->pageTextContains('The untranslated field value'); - - // Adjust the layout. - $assert_session->linkExists('Add block'); - $this->clickLink('Add block'); - $assert_session->linkExists('Powered by Drupal'); - $this->clickLink('Powered by Drupal'); - $page->pressButton('Add block'); - - $assert_session->pageTextContains('Powered by Drupal'); - $assert_session->buttonExists('Save layout'); - $page->pressButton('Save layout'); - } - } diff --git a/core/modules/layout_builder/tests/src/Functional/Rest/LayoutBuilderEntityViewDisplayResourceTestBase.php b/core/modules/layout_builder/tests/src/Functional/Rest/LayoutBuilderEntityViewDisplayResourceTestBase.php index 7d28bda963..b286601238 100644 --- a/core/modules/layout_builder/tests/src/Functional/Rest/LayoutBuilderEntityViewDisplayResourceTestBase.php +++ b/core/modules/layout_builder/tests/src/Functional/Rest/LayoutBuilderEntityViewDisplayResourceTestBase.php @@ -36,6 +36,7 @@ protected function getExpectedNormalizedEntity() { $expected = parent::getExpectedNormalizedEntity(); array_unshift($expected['dependencies']['module'], 'layout_builder'); $expected['hidden'][OverridesSectionStorage::FIELD_NAME] = TRUE; + $expected['hidden'][OverridesSectionStorage::TRANSLATED_CONFIGURATION_FIELD_NAME] = TRUE; $expected['third_party_settings']['layout_builder'] = [ 'enabled' => TRUE, 'allow_custom' => TRUE, diff --git a/core/modules/layout_builder/tests/src/Functional/TranslationTestTrait.php b/core/modules/layout_builder/tests/src/Functional/TranslationTestTrait.php new file mode 100644 index 0000000000..3efaee49f3 --- /dev/null +++ b/core/modules/layout_builder/tests/src/Functional/TranslationTestTrait.php @@ -0,0 +1,24 @@ +<?php + +namespace Drupal\Tests\layout_builder\Functional; + +/** + * Common functions for testing Layout Builder with translations. + */ +trait TranslationTestTrait { + + /** + * Asserts that non-trans actions have been removed. + */ + protected function assertNonTranslationActionsRemoved() { + /** @var \Drupal\Tests\WebAssert $assert_session */ + $assert_session = $this->assertSession(); + // Confirm that links do not exist to change the layout. + $assert_session->linkNotExists('Add Section'); + $assert_session->linkNotExists('Add block'); + $assert_session->linkNotExists('Remove section'); + $assert_session->elementNotExists('css', '[data-contextual-id^="layout_builder_block:"]'); + $assert_session->buttonNotExists('Revert to defaults'); + } + +} diff --git a/core/modules/layout_builder/tests/src/Functional/Update/Translatability/MakeLayoutUntranslatableUpdatePathTestBase.php b/core/modules/layout_builder/tests/src/Functional/Update/Translatability/MakeLayoutUntranslatableUpdatePathTestBase.php new file mode 100644 index 0000000000..4ccea61706 --- /dev/null +++ b/core/modules/layout_builder/tests/src/Functional/Update/Translatability/MakeLayoutUntranslatableUpdatePathTestBase.php @@ -0,0 +1,85 @@ +<?php + +namespace Drupal\Tests\layout_builder\Functional\Update\Translatability; + +use Drupal\field\Entity\FieldConfig; +use Drupal\field\Entity\FieldStorageConfig; +use Drupal\FunctionalTests\Update\UpdatePathTestBase; +use Drupal\layout_builder\Plugin\SectionStorage\OverridesSectionStorage; + +/** + * Base class for upgrade path for translatable layouts. + * + * Each class that extends this class will test 1 case for including 2 content + * types. + * + * This method of testing is used instead of a data provider method because test + * fixtures do not have access to the provide data. This allows varying fixture + * data per test case. + * + * @see layout_builder_post_update_make_layout_untranslatable() + */ +abstract class MakeLayoutUntranslatableUpdatePathTestBase extends UpdatePathTestBase { + + /** + * Layout builder test cases. + * + * Keys are bundle names. Values are test cases including keys: + * - has_translation + * - has_layout + * - vid + * - nid + * + * @var array + */ + protected $layoutBuilderTestCases; + + /** + * Expectations of field updates by bundles. + * + * @var array + */ + protected $expectedBundleUpdates; + + /** + * Whether the field storage should be updated. + * + * @var bool + */ + protected $expectedFieldStorageUpdate; + + /** + * {@inheritdoc} + */ + protected function setDatabaseDumpFiles() { + $this->databaseDumpFiles = [ + __DIR__ . '/../../../../../../system/tests/fixtures/update/drupal-8.filled.standard.php.gz', + __DIR__ . '/../../../../fixtures/update/layout-builder.php', + __DIR__ . '/../../../../fixtures/update/layout-builder-field-schema.php', + __DIR__ . '/../../../../fixtures/update/layout-builder-translation.php', + ]; + } + + /** + * Tests the upgrade path for translatable layouts. + * + * @see layout_builder_post_update_make_layout_untranslatable() + */ + public function testDisableTranslationOnLayouts() { + $this->runUpdates(); + foreach ($this->expectedBundleUpdates as $bundle => $field_update_expected) { + $this->assertEquals( + $field_update_expected, + !FieldConfig::loadByName('node', $bundle, OverridesSectionStorage::FIELD_NAME)->isTranslatable(), + $field_update_expected ? "Field on $bundle set to be non-translatable." : "Field on $bundle not set to non-translatable." + ); + $this->assertNotEmpty(FieldConfig::loadByName('node', $bundle, OverridesSectionStorage::TRANSLATED_CONFIGURATION_FIELD_NAME), "Translation field for $bundle was created"); + } + + $this->assertEquals( + $this->expectedFieldStorageUpdate, + !FieldStorageConfig::loadByName('node', OverridesSectionStorage::FIELD_NAME)->isTranslatable() + ); + } + +} diff --git a/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockTranslationTest.php b/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockTranslationTest.php new file mode 100644 index 0000000000..02d030dcf7 --- /dev/null +++ b/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockTranslationTest.php @@ -0,0 +1,243 @@ +<?php + +namespace Drupal\Tests\layout_builder\FunctionalJavascript; + +use Drupal\language\Entity\ConfigurableLanguage; +use Drupal\Core\Url; +use Drupal\Tests\layout_builder\Functional\TranslationTestTrait; + +/** + * Tests that inline blocks works with content translation. + * + * @group layout_builder + */ +class InlineBlockTranslationTest extends InlineBlockTestBase { + + use LayoutBuilderTestTrait; + use TranslationTestTrait; + use JavascriptTranslationTestTrait; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'content_translation', + ]; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'classy'; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + // Adds a new language. + ConfigurableLanguage::createFromLangcode('it')->save(); + + // Enable translation for the node type 'bundle_with_section_field'. + \Drupal::service('content_translation.manager')->setEnabled('node', 'bundle_with_section_field', TRUE); + } + + /** + * Tests that inline blocks works with content translation. + */ + public function testInlineBlockContentTranslation() { + $assert_session = $this->assertSession(); + + $this->drupalLogin($this->drupalCreateUser([ + 'access contextual links', + 'configure any layout', + 'administer node display', + 'administer node fields', + 'translate bundle_with_section_field node', + 'create content translations', + 'create and edit custom blocks', + ])); + + // Allow layout overrides. + $this->drupalGet(static::FIELD_UI_PREFIX . '/display/default'); + $this->submitForm(['layout[enabled]' => TRUE], + 'Save' + ); + $this->drupalGet(static::FIELD_UI_PREFIX . '/display/default'); + $this->submitForm(['layout[allow_custom]' => TRUE], + 'Save' + ); + + // Add a new inline block to the original node. + $this->drupalGet('node/1/layout'); + $this->addInlineBlockToLayout('Block en label', 'Block en body'); + $this->assertSaveLayout(); + $this->drupalGet('node/1'); + $assert_session->pageTextContains('Block en label'); + $assert_session->pageTextContains('Block en body'); + $block_id = $this->getLatestBlockEntityId(); + + // Create a translation. + $add_translation_url = Url::fromRoute("entity.node.content_translation_add", [ + 'node' => 1, + 'source' => 'en', + 'target' => 'it', + ]); + $this->drupalGet($add_translation_url); + $this->submitForm([ + 'title[0][value]' => 'The translated node title', + 'body[0][value]' => 'The translated node body', + ], 'Save'); + + // Update the translate node's inline block. + $this->drupalGet('it/node/1/layout'); + $this->assertNonTranslationActionsRemoved(); + + $this->updateBlockTranslation( + static::INLINE_BLOCK_LOCATOR, + 'Block en label', + 'Block it label', + '', + ['[name="settings[block_form][body][0][value]"]'] + ); + + $this->assertSaveLayout(); + $this->assertEquals($block_id, $this->getLatestBlockEntityId(), 'A new block was not created.'); + $this->blockStorage->resetCache([$block_id]); + /** @var \Drupal\block_content\BlockContentInterface $block */ + $block = $this->blockStorage->load($block_id); + $this->assertFalse($block->hasTranslation('it'), 'A block translation was not created when only the label was translatable.'); + + // Enable translation for block_content type 'bundle_with_section_field'. + \Drupal::service('content_translation.manager')->setEnabled('block_content', 'basic', TRUE); + + // Update the translate node's inline block. + $this->drupalGet('it/node/1/layout'); + $this->assertNonTranslationActionsRemoved(); + $this->updateTranslatedBlock('Block it label', 'Block en body', 'Block updated it label', 'Block it body'); + + $this->assertEquals($block_id, $this->getLatestBlockEntityId(), 'A new block was not created.'); + $this->blockStorage->resetCache([$block_id]); + /** @var \Drupal\block_content\BlockContentInterface $block */ + $block = $this->blockStorage->load($block_id); + $this->assertFalse($block->hasTranslation('it'), 'A block translation was not created before the layout was saved.'); + + $this->assertSaveLayout(); + $this->assertEquals($block_id, $this->getLatestBlockEntityId(), 'A new block was not created'); + $this->blockStorage->resetCache([$block_id]); + /** @var \Drupal\block_content\BlockContentInterface $block */ + $block = $this->blockStorage->load($block_id); + $this->assertTrue($block->hasTranslation('it'), 'A block translation was created when the layout was saved.'); + $block_translation = $block->getTranslation('it'); + $this->assertEquals('Block it body', $block_translation->get('body')->get(0)->getValue()['value'], 'The translated block body field was created correctly.'); + + $assert_session->addressEquals('it/node/1'); + $assert_session->pageTextContains('Block it body'); + $assert_session->pageTextContains('Block updated it label'); + $assert_session->pageTextNotContains('Block en body'); + $assert_session->pageTextNotContains('Block en label'); + + // Confirm that the default translation was not effected. + $this->drupalGet('node/1'); + $assert_session->pageTextNotContains('Block it body'); + $assert_session->pageTextNotContains('Block updated it label'); + $assert_session->pageTextContains('Block en body'); + $assert_session->pageTextContains('Block en label'); + + // Update the translation inline block again. + $this->drupalGet('it/node/1/layout'); + $this->updateTranslatedBlock('Block updated it label', 'Block it body', 'Block newer updated it label', 'Block updated it body'); + $this->assertSaveLayout(); + + $this->assertEquals($block_id, $this->getLatestBlockEntityId(), 'A new block was not created.'); + $this->blockStorage->resetCache([$block_id]); + $block = $this->blockStorage->load($block_id); + $block_translation = $block->getTranslation('it'); + $this->assertEquals('Block updated it body', $block_translation->get('body')->get(0)->getValue()['value'], 'The translated block body field was created correctly.'); + + $assert_session->addressEquals('it/node/1'); + $assert_session->pageTextContains('Block updated it body'); + $assert_session->pageTextContains('Block newer updated it label'); + $assert_session->pageTextNotContains('Block en body'); + $assert_session->pageTextNotContains('Block en label'); + + // Confirm that the default translation was not effected. + $this->drupalGet('node/1'); + $assert_session->pageTextNotContains('Block updated it body'); + $assert_session->pageTextNotContains('Block newer updated it label'); + $assert_session->pageTextContains('Block en body'); + $assert_session->pageTextContains('Block en label'); + + // Update the default translation's version of the block. + $this->drupalGet('node/1/layout'); + $this->configureInlineBlock('Block en body', 'Block updated en body'); + $this->assertSaveLayout(); + + $assert_session->addressEquals('node/1'); + $assert_session->pageTextNotContains('Block updated it body'); + $assert_session->pageTextNotContains('Block newer updated it label'); + $assert_session->pageTextContains('Block updated en body'); + $assert_session->pageTextContains('Block en label'); + + // Confirm that the translation was not effected. + $this->drupalGet('it/node/1'); + $assert_session->pageTextContains('Block updated it body'); + $assert_session->pageTextContains('Block newer updated it label'); + $assert_session->pageTextNotContains('Block updated en body'); + $assert_session->pageTextNotContains('Block en label'); + + // Update the translation block after updating default translation block. + $this->drupalGet('it/node/1/layout'); + $this->updateTranslatedBlock('Block newer updated it label', 'Block updated it body', 'Block even newer updated it label', 'Block newer updated it body'); + $this->assertSaveLayout(); + + $assert_session->addressEquals('it/node/1'); + $assert_session->pageTextContains('Block newer updated it body'); + $assert_session->pageTextContains('Block even newer updated it label'); + $assert_session->pageTextNotContains('Block updated en body'); + $assert_session->pageTextNotContains('Block en label'); + + $this->drupalGet('node/1'); + $assert_session->pageTextNotContains('Block newer updated it body'); + $assert_session->pageTextNotContains('Block even newer updated it label'); + $assert_session->pageTextContains('Block updated en body'); + $assert_session->pageTextContains('Block en label'); + } + + /** + * Update a translation inline block. + * + * @param string $existing_label + * The inline block's existing label. + * @param string $existing_body + * The inline block's existing body field value. + * @param string $new_label + * The new label. + * @param string $new_body + * The new body field value. + */ + protected function updateTranslatedBlock($existing_label, $existing_body, $new_label, $new_body) { + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + $this->clickContextualLink(static::INLINE_BLOCK_LOCATOR, 'Translate block'); + $textarea = $assert_session->waitForElement('css', '[name="body[0][value]"]'); + $this->assertNotEmpty($textarea); + $this->assertEquals($existing_body, $textarea->getValue()); + $textarea->setValue($new_body); + + $label_input = $assert_session->elementExists('css', '#drupal-off-canvas [name="info[0][value]"]'); + $this->assertNotEmpty($label_input); + $this->assertEquals($existing_label, $label_input->getValue()); + $label_input->setValue($new_label); + $page->pressButton('Save'); + + $this->assertNoElementAfterWait('#drupal-off-canvas'); + $assert_session->assertWaitOnAjaxRequest(); + + $assert_session->pageTextContains($new_label); + $assert_session->pageTextContains($new_body); + $assert_session->pageTextNotContains($existing_label); + $assert_session->pageTextNotContains($existing_body); + } + +} diff --git a/core/modules/layout_builder/tests/src/FunctionalJavascript/JavascriptTranslationTestTrait.php b/core/modules/layout_builder/tests/src/FunctionalJavascript/JavascriptTranslationTestTrait.php new file mode 100644 index 0000000000..83a5d1de84 --- /dev/null +++ b/core/modules/layout_builder/tests/src/FunctionalJavascript/JavascriptTranslationTestTrait.php @@ -0,0 +1,52 @@ +<?php + +namespace Drupal\Tests\layout_builder\FunctionalJavascript; + +/** + * Common functions for testing Layout Builder with translations. + */ +trait JavascriptTranslationTestTrait { + + /** + * Whether the test is using config_translation. + * + * @var bool + */ + protected $usingConfigTranslation = FALSE; + + /** + * Updates a block label translation. + * + * @param string $block_selector + * The CSS selector for the block. + * @param string $untranslated_label + * The label untranslated. + * @param string $new_label + * The new label to set. + * @param string $expected_label + * The expected existing translated label. + * @param array $unexpected_element_selectors + * A list of selectors for elements that should be present. + */ + protected function updateBlockTranslation($block_selector, $untranslated_label, $new_label, $expected_label = '', array $unexpected_element_selectors = []) { + /** @var \Drupal\Tests\WebAssert $assert_session */ + $assert_session = $this->assertSession(); + /** @var \Behat\Mink\Element\DocumentElement $page */ + $page = $this->getSession()->getPage(); + + $translation_selector_prefix = $this->usingConfigTranslation ? '#drupal-off-canvas .translation-set ' : '#drupal-off-canvas '; + $this->clickContextualLink($block_selector, 'Translate block'); + $label_input = $assert_session->waitForElementVisible('css', $translation_selector_prefix . '[name="translation[label]"]'); + $this->assertNotEmpty($label_input); + $this->assertEquals($expected_label, $label_input->getValue()); + $assert_session->elementTextContains('css', $translation_selector_prefix . '.form-item-source-label', $untranslated_label); + $label_input->setValue($new_label); + foreach ($unexpected_element_selectors as $unexpected_element_selector) { + $assert_session->elementNotExists('css', $unexpected_element_selector); + } + $page->pressButton('Translate'); + $this->assertNoElementAfterWait('#drupal-off-canvas'); + $this->assertNotEmpty($assert_session->waitForElementVisible('css', "h2:contains(\"$new_label\")")); + } + +} diff --git a/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderTestTrait.php b/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderTestTrait.php new file mode 100644 index 0000000000..237d9ebfee --- /dev/null +++ b/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderTestTrait.php @@ -0,0 +1,72 @@ +<?php + +namespace Drupal\Tests\layout_builder\FunctionalJavascript; + +/** + * Common functions for testing Layout Builder. + */ +trait LayoutBuilderTestTrait { + + /** + * Adds a block in the Layout Builder. + * + * @param string $block_link_text + * The link text to add the block. + * @param string $rendered_locator + * The CSS locator to confirm the block was rendered. + * @param bool $label_display + * Whether the label should be displayed. + * @param string|null $label + * The label use. + */ + protected function addBlock($block_link_text, $rendered_locator, $label_display = FALSE, $label = NULL) { + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + + // Add a new block. + $this->assertNotEmpty($assert_session->waitForElementVisible('css', '#layout-builder a:contains(\'Add block\')')); + $this->clickLink('Add block'); + $this->assertNotEmpty($assert_session->waitForElementVisible('css', '#drupal-off-canvas')); + $assert_session->assertWaitOnAjaxRequest(); + + $assert_session->linkExists($block_link_text); + $this->clickLink($block_link_text); + + // Wait for off-canvas dialog to reopen with block form. + $this->assertNotEmpty($assert_session->waitForElementVisible('css', ".layout-builder-add-block")); + $assert_session->assertWaitOnAjaxRequest(); + if ($label_display) { + $page->checkField('settings[label_display]'); + } + if ($label !== NULL) { + $page->fillField('settings[label]', $label); + } + $page->pressButton('Add block'); + + // Wait for block form to be rendered in the Layout Builder. + $this->assertNotEmpty($assert_session->waitForElement('css', $rendered_locator)); + } + + /** + * Waits for an element to be removed from the page. + * + * @param string $selector + * CSS selector. + * @param int $timeout + * (optional) Timeout in milliseconds, defaults to 10000. + * @param string $message + * (optional) Custom message to display with the assertion. + * + * @todo: Remove after https://www.drupal.org/project/drupal/issues/2892440 + */ + public function assertNoElementAfterWait($selector, $timeout = 10000, $message = '') { + $page = $this->getSession()->getPage(); + if ($message === '') { + $message = "Element '$selector' was not on the page after wait."; + } + $this->assertTrue($page->waitFor($timeout / 1000, function () use ($page, $selector) { + return empty($page->find('css', $selector)); + }), $message); + } + +} diff --git a/core/modules/layout_builder/tests/src/FunctionalJavascript/ModeratedTranslationTest.php b/core/modules/layout_builder/tests/src/FunctionalJavascript/ModeratedTranslationTest.php new file mode 100644 index 0000000000..e920a3a722 --- /dev/null +++ b/core/modules/layout_builder/tests/src/FunctionalJavascript/ModeratedTranslationTest.php @@ -0,0 +1,250 @@ +<?php + +namespace Drupal\Tests\layout_builder\FunctionalJavascript; + +use Drupal\Core\Url; +use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use Drupal\language\Entity\ConfigurableLanguage; +use Drupal\node\Entity\Node; +use Drupal\Tests\content_moderation\Traits\ContentModerationTestTrait; +use Drupal\Tests\contextual\FunctionalJavascript\ContextualLinkClickTrait; +use Drupal\Tests\layout_builder\Functional\TranslationTestTrait; + +/** + * Test moderated and translated layout overrides. + * + * @group layout_builder + */ +class ModeratedTranslationTest extends WebDriverTestBase { + + use LayoutBuilderTestTrait; + use TranslationTestTrait; + use JavascriptTranslationTestTrait; + use ContextualLinkClickTrait; + use ContentModerationTestTrait; + + /** + * Path prefix for the field UI for the test bundle. + */ + const FIELD_UI_PREFIX = 'admin/structure/types/manage/bundle_with_section_field'; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'content_translation', + 'content_moderation', + 'layout_builder', + 'block', + 'node', + 'contextual', + 'layout_builder_test', + 'block_test', + ]; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'classy'; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $page = $this->getSession()->getPage(); + $this->container->get('state')->set('test_block_access', TRUE); + + // @todo The Layout Builder UI relies on local tasks; fix in + // https://www.drupal.org/project/drupal/issues/2917777. + $this->drupalPlaceBlock('local_tasks_block'); + + $this->createContentType(['type' => 'bundle_with_section_field', 'new_revision' => TRUE]); + $workflow = $this->createEditorialWorkflow(); + $workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'bundle_with_section_field'); + $workflow->save(); + + // Adds a new language. + ConfigurableLanguage::createFromLangcode('it')->save(); + + // Enable translation for the node type 'bundle_with_section_field'. + \Drupal::service('content_translation.manager')->setEnabled('node', 'bundle_with_section_field', TRUE); + + $this->drupalLogin($this->drupalCreateUser([ + 'access contextual links', + 'configure any layout', + 'administer node display', + 'administer node fields', + 'translate bundle_with_section_field node', + 'create content translations', + 'edit any bundle_with_section_field content', + 'view bundle_with_section_field revisions', + 'revert bundle_with_section_field revisions', + 'view own unpublished content', + 'view latest version', + 'use editorial transition create_new_draft', + 'use editorial transition publish', + ])); + + $node = $this->createNode([ + 'type' => 'bundle_with_section_field', + 'title' => 'The node title', + 'body' => [ + [ + 'value' => 'The node body', + ], + ], + ]); + + $this->drupalGet('node/1'); + + // Create a translation. + $add_translation_url = Url::fromRoute("entity.node.content_translation_add", [ + 'node' => 1, + 'source' => 'en', + 'target' => 'it', + ]); + $this->drupalGet($add_translation_url); + $this->submitForm([ + 'title[0][value]' => 'The translated node title', + 'body[0][value]' => 'The translated node body', + ], 'Save'); + + // Allow layout overrides. + $this->drupalGet(static::FIELD_UI_PREFIX . '/display/default'); + $this->submitForm( + ['layout[enabled]' => TRUE], + 'Save' + ); + $this->drupalGet(static::FIELD_UI_PREFIX . '/display/default'); + $this->submitForm( + ['layout[allow_custom]' => TRUE], + 'Save' + ); + + // Publish both nodes. + $this->drupalGet($node->toUrl()); + $page->fillField('new_state', 'published'); + $page->pressButton('Apply'); + + // Modify the layout. + $this->drupalGet('it/node/1'); + $page->fillField('new_state', 'published'); + $page->pressButton('Apply'); + } + + /** + * Tests a layout overrides that are moderated and translated. + */ + public function testModerationTranslatedOverrides() { + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + + $node = Node::load(1); + + // Create a draft layout override. + $this->drupalGet($node->toUrl()); + $page->clickLink('Layout'); + $assert_session->checkboxChecked('revision'); + $assert_session->fieldDisabled('revision'); + + $this->addBlock('Powered by Drupal', '.block-system-powered-by-block', TRUE, 'untranslated label'); + $page->fillField('moderation_state[0][state]', 'draft'); + $page->pressButton('Save layout'); + + // Modify the layout. + $this->drupalGet('it/node/1'); + // Layout link does not exist for translation because published default + // translation has no override. + $assert_session->elementNotExists('css', '[data-drupal-link-system-path="node/1/layout"]'); + + // Publish the override. + $this->drupalGet($node->toUrl()); + $page->clickLink('Layout'); + $page->fillField('moderation_state[0][state]', 'published'); + $page->pressButton('Save layout'); + + $assert_session->addressEquals('node/1'); + $assert_session->pageTextContains('untranslated label'); + + $this->drupalGet('it/node/1'); + // Layout link exists for the translation after publish default translation + // has an override. + $assert_session->elementExists('css', '[data-drupal-link-system-path="node/1/layout"]'); + $page->clickLink('Layout'); + + $assert_session->checkboxChecked('revision'); + $assert_session->fieldDisabled('revision'); + $assert_session->pageTextContains('untranslated label'); + $this->assertNonTranslationActionsRemoved(); + $this->updateBlockTranslation('.block-system-powered-by-block', 'untranslated label', 'label in translation'); + $page->fillField('moderation_state[0][state]', 'draft'); + $page->pressButton('Save layout'); + + // The translate draft label is not show in any publish revision yet. + $this->drupalGet($node->toUrl()); + $assert_session->pageTextContains('untranslated label'); + $assert_session->pageTextNotContains('label in translation'); + $page->clickLink('Layout'); + $assert_session->pageTextContains('untranslated label'); + $assert_session->pageTextNotContains('label in translation'); + + $this->drupalGet('it/node/1'); + $assert_session->pageTextContains('untranslated label'); + $assert_session->pageTextNotContains('label in translation'); + + $page->clickLink('Latest version'); + $assert_session->pageTextContains('label in translation'); + + // Add a new block to the default translation override. + $this->drupalGet($node->toUrl()); + $page->clickLink('Layout'); + $this->addBlock('Test block access', '#layout-builder .block-test-access', TRUE, 'untranslated new label'); + $page->fillField('moderation_state[0][state]', 'draft'); + $page->pressButton('Save layout'); + + $this->drupalGet('it/node/1'); + $this->clickLink('Layout'); + $assert_session->pageTextContains('label in translation'); + $assert_session->pageTextNotContains('untranslated new label'); + + // Publish draft default translation with new block. + $this->drupalGet($node->toUrl()); + $page->clickLink('Layout'); + $page->fillField('moderation_state[0][state]', 'published'); + $page->pressButton('Save layout'); + $assert_session->addressEquals('node/1'); + $assert_session->pageTextContains('untranslated new label'); + + $this->drupalGet('it/node/1'); + // New block in published default translation exists in published + // translation. + $assert_session->pageTextContains('untranslated new label'); + $page->clickLink('Latest version'); + // New block in published default translation does not exist in existing + // draft. + $assert_session->pageTextNotContains('untranslated new label'); + $this->clickLink('Layout'); + $assert_session->pageTextContains('label in translation'); + // New block in published default translation does not exist in existing + // draft. + $assert_session->pageTextNotContains('untranslated new label'); + $page->fillField('moderation_state[0][state]', 'published'); + $page->pressButton('Save layout'); + + $assert_session->addressEquals('it/node/1'); + $assert_session->pageTextContains('label in translation'); + + $assert_session->pageTextNotContains('untranslated label'); + $assert_session->pageTextContains('untranslated new label'); + + // The default translation still uses the untranslated label. + $this->drupalGet('node/1'); + $assert_session->pageTextContains('untranslated label'); + $assert_session->pageTextNotContains('label in translation'); + $page->clickLink('Layout'); + $assert_session->pageTextContains('untranslated label'); + $assert_session->pageTextNotContains('label in translation'); + } + +} diff --git a/core/modules/layout_builder/tests/src/FunctionalJavascript/TranslationTest.php b/core/modules/layout_builder/tests/src/FunctionalJavascript/TranslationTest.php new file mode 100644 index 0000000000..5978e1aa17 --- /dev/null +++ b/core/modules/layout_builder/tests/src/FunctionalJavascript/TranslationTest.php @@ -0,0 +1,176 @@ +<?php + +namespace Drupal\Tests\layout_builder\FunctionalJavascript; + +use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use Drupal\language\Entity\ConfigurableLanguage; +use Drupal\Core\Url; +use Drupal\Tests\contextual\FunctionalJavascript\ContextualLinkClickTrait; +use Drupal\Tests\layout_builder\Functional\TranslationTestTrait; + +/** + * Tests that block settings can be translated. + * + * @group layout_builder + */ +class TranslationTest extends WebDriverTestBase { + + use LayoutBuilderTestTrait; + use TranslationTestTrait; + use JavascriptTranslationTestTrait; + use ContextualLinkClickTrait; + + /** + * Path prefix for the field UI for the test bundle. + */ + const FIELD_UI_PREFIX = 'admin/structure/types/manage/bundle_with_section_field'; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'content_translation', + 'layout_builder', + 'block', + 'node', + 'contextual', + ]; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'classy'; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + // @todo The Layout Builder UI relies on local tasks; fix in + // https://www.drupal.org/project/drupal/issues/2917777. + $this->drupalPlaceBlock('local_tasks_block'); + + $this->createContentType(['type' => 'bundle_with_section_field', 'new_revision' => TRUE]); + $this->createNode([ + 'type' => 'bundle_with_section_field', + 'title' => 'The node title', + 'body' => [ + [ + 'value' => 'The node body', + ], + ], + ]); + // Adds a new language. + ConfigurableLanguage::createFromLangcode('it')->save(); + + // Enable translation for the node type 'bundle_with_section_field'. + \Drupal::service('content_translation.manager')->setEnabled('node', 'bundle_with_section_field', TRUE); + + $this->drupalLogin($this->drupalCreateUser([ + 'access contextual links', + 'configure any layout', + 'administer node display', + 'administer node fields', + 'translate bundle_with_section_field node', + 'create content translations', + ])); + + // Create a translation. + $add_translation_url = Url::fromRoute("entity.node.content_translation_add", [ + 'node' => 1, + 'source' => 'en', + 'target' => 'it', + ]); + $this->drupalGet($add_translation_url); + $this->submitForm([ + 'title[0][value]' => 'The translated node title', + 'body[0][value]' => 'The translated node body', + ], 'Save'); + + // Allow layout overrides. + $this->drupalGet(static::FIELD_UI_PREFIX . '/display/default'); + $this->submitForm(['layout[enabled]' => TRUE], + 'Save' + ); + $this->drupalGet(static::FIELD_UI_PREFIX . '/display/default'); + $this->submitForm(['layout[allow_custom]' => TRUE], + 'Save' + ); + } + + /** + * DataProvider for testLabelTranslation(). + */ + public function providerLabelTranslation() { + return [ + 'with config_translation' => [TRUE], + 'without config_translation' => [FALSE], + ]; + } + + /** + * Tests that block labels can be translated. + * + * @dataProvider providerLabelTranslation + */ + public function testLabelTranslation($install_config_translation) { + $page = $this->getSession()->getPage(); + $assert_session = $this->assertSession(); + + $this->usingConfigTranslation = $install_config_translation; + if ($install_config_translation) { + $this->container->get('module_installer')->install(['config_translation']); + } + + $this->drupalLogin($this->drupalCreateUser([ + 'access contextual links', + 'configure any layout', + // @todo should you need this permission? You don't actually save the + // entity translation because labels are stored with untranslated + // layout. + // 'translate bundle_with_section_field node'. + ])); + + // Add a new inline block to the original node. + $this->drupalGet('node/1/layout'); + + $this->clickContextualLink('.block-field-blocknodebundle-with-section-fieldbody', 'Configure'); + $this->assertNotEmpty($assert_session->waitForElementVisible('css', '#drupal-off-canvas')); + $page->fillField('settings[label]', 'field label untranslated'); + $page->checkField('settings[label_display]'); + $page->pressButton('Update'); + $assert_session->assertWaitOnAjaxRequest(); + $this->assertNoElementAfterWait('#drupal-off-canvas'); + $this->addBlock('Powered by Drupal', '.block-system-powered-by-block', TRUE, 'untranslated label'); + $assert_session->pageTextContains('untranslated label'); + $assert_session->buttonExists('Save layout'); + $page->pressButton('Save layout'); + $assert_session->addressEquals('node/1'); + + // Update the translations block label. + $this->drupalGet('it/node/1/layout'); + $this->assertNonTranslationActionsRemoved(); + $this->updateBlockTranslation('.block-system-powered-by-block', 'untranslated label', 'label in translation'); + $assert_session->buttonExists('Save layout'); + $page->pressButton('Save layout'); + $assert_session->addressEquals('it/node/1'); + $assert_session->pageTextContains('label in translation'); + $assert_session->pageTextNotContains('untranslated label'); + + // Confirm that untranslated label is still used on default translation. + $this->drupalGet('node/1'); + $assert_session->pageTextContains('untranslated label'); + $assert_session->pageTextNotContains('label in translation'); + + // Update the translations block label. + $this->drupalGet('it/node/1/layout'); + $this->assertNonTranslationActionsRemoved(); + $this->updateBlockTranslation('.block-system-powered-by-block', 'untranslated label', 'label updated in translation', 'label in translation'); + $assert_session->buttonExists('Save layout'); + $page->pressButton('Save layout'); + $assert_session->addressEquals('it/node/1'); + $assert_session->pageTextContains('label updated in translation'); + $assert_session->pageTextNotContains('label in translation'); + } + +} diff --git a/core/modules/layout_builder/tests/src/Kernel/OverridesSectionStorageTest.php b/core/modules/layout_builder/tests/src/Kernel/OverridesSectionStorageTest.php index f247117b26..b5bad1c85d 100644 --- a/core/modules/layout_builder/tests/src/Kernel/OverridesSectionStorageTest.php +++ b/core/modules/layout_builder/tests/src/Kernel/OverridesSectionStorageTest.php @@ -6,6 +6,7 @@ use Drupal\Core\Plugin\Context\ContextDefinition; use Drupal\Core\Plugin\Context\EntityContext; use Drupal\entity_test\Entity\EntityTest; +use Drupal\entity_test\Entity\EntityTestMul; use Drupal\KernelTests\KernelTestBase; use Drupal\language\Entity\ConfigurableLanguage; use Drupal\layout_builder\DefaultsSectionStorageInterface; @@ -35,6 +36,8 @@ class OverridesSectionStorageTest extends KernelTestBase { 'system', 'user', 'language', + 'content_translation', + 'content_translation_test', ]; /** @@ -52,6 +55,8 @@ protected function setUp(): void { $this->setUpCurrentUser(); $this->installEntitySchema('entity_test'); + $this->installEntitySchema('entity_test_mul'); + $this->installEntitySchema('user'); $definition = $this->container->get('plugin.manager.layout_builder.section_storage')->getDefinition('overrides'); $this->plugin = OverridesSectionStorage::create($this->container, [], 'overrides', $definition); @@ -71,9 +76,10 @@ protected function setUp(): void { * An array of permissions to grant to the user. */ public function testAccess($expected, $is_enabled, array $section_data, array $permissions) { + ConfigurableLanguage::createFromLangcode('es')->save(); $display = LayoutBuilderEntityViewDisplay::create([ - 'targetEntityType' => 'entity_test', - 'bundle' => 'entity_test', + 'targetEntityType' => 'entity_test_mul', + 'bundle' => 'entity_test_mul', 'mode' => 'default', 'status' => TRUE, ]); @@ -84,7 +90,7 @@ public function testAccess($expected, $is_enabled, array $section_data, array $p ->setOverridable() ->save(); - $entity = EntityTest::create([OverridesSectionStorage::FIELD_NAME => $section_data]); + $entity = EntityTestMul::create([OverridesSectionStorage::FIELD_NAME => $section_data]); $entity->save(); $account = $this->setUpCurrentUser([], $permissions); @@ -99,18 +105,17 @@ public function testAccess($expected, $is_enabled, array $section_data, array $p $this->assertSame($expected, $result); // Create a translation. - ConfigurableLanguage::createFromLangcode('es')->save(); - $entity = EntityTest::load($entity->id()); $translation = $entity->addTranslation('es'); $translation->save(); $this->plugin->setContext('entity', EntityContext::fromEntity($translation)); + // Translation access should only be allowed when there is section data. + $translation_expected_access = $expected && !empty($section_data); - // Perform the same checks again but with a non default translation which - // should always deny access. + // Perform the same checks again but with a non default translation. $result = $this->plugin->access('view'); - $this->assertFalse($result); + $this->assertSame($translation_expected_access, $result); $result = $this->plugin->access('view', $account); - $this->assertFalse($result); + $this->assertSame($translation_expected_access, $result); } /** @@ -148,22 +153,22 @@ public function providerTestAccess() { TRUE, TRUE, $section_data, ['configure any layout'], ]; $data['enabled, no data, bundle overrides'] = [ - TRUE, TRUE, [], ['configure all entity_test entity_test layout overrides'], + TRUE, TRUE, [], ['configure all entity_test_mul entity_test_mul layout overrides'], ]; $data['enabled, data, bundle overrides'] = [ - TRUE, TRUE, $section_data, ['configure all entity_test entity_test layout overrides'], + TRUE, TRUE, $section_data, ['configure all entity_test_mul entity_test_mul layout overrides'], ]; $data['enabled, no data, bundle edit overrides, no edit access'] = [ - FALSE, TRUE, [], ['configure editable entity_test entity_test layout overrides'], + FALSE, TRUE, [], ['configure editable entity_test_mul entity_test_mul layout overrides'], ]; $data['enabled, data, bundle edit overrides, no edit access'] = [ - FALSE, TRUE, $section_data, ['configure editable entity_test entity_test layout overrides'], + FALSE, TRUE, $section_data, ['configure editable entity_test_mul entity_test_mul layout overrides'], ]; $data['enabled, no data, bundle edit overrides, edit access'] = [ - TRUE, TRUE, [], ['configure editable entity_test entity_test layout overrides', 'administer entity_test content'], + TRUE, TRUE, [], ['configure editable entity_test_mul entity_test_mul layout overrides', 'administer entity_test content'], ]; $data['enabled, data, bundle edit overrides, edit access'] = [ - TRUE, TRUE, $section_data, ['configure editable entity_test entity_test layout overrides', 'administer entity_test content'], + TRUE, TRUE, $section_data, ['configure editable entity_test_mul entity_test_mul layout overrides', 'administer entity_test content'], ]; return $data; } diff --git a/core/modules/layout_builder/tests/src/Unit/OverridesSectionStorageTest.php b/core/modules/layout_builder/tests/src/Unit/OverridesSectionStorageTest.php index 4e7fd27d41..a3612230f3 100644 --- a/core/modules/layout_builder/tests/src/Unit/OverridesSectionStorageTest.php +++ b/core/modules/layout_builder/tests/src/Unit/OverridesSectionStorageTest.php @@ -137,6 +137,18 @@ public function providerTestExtractEntityFromRoute() { 'my_entity_type.entity_without_layout', [], ]; + $data['with value, with layout, fr'] = [ + TRUE, + 'my_entity_type', + 'my_entity_type.entity_with_layout.fr', + [], + ]; + $data['with value, without layout, fr'] = [ + FALSE, + 'my_entity_type', + 'my_entity_type.entity_without_layout.fr', + [], + ]; $data['empty value, populated defaults'] = [ TRUE, 'my_entity_type', @@ -146,6 +158,18 @@ public function providerTestExtractEntityFromRoute() { 'my_entity_type' => 'entity_with_layout', ], ]; + $data['with value, with layout, fr'] = [ + TRUE, + 'my_entity_type', + 'my_entity_type.entity_with_layout.fr', + [], + ]; + $data['with value, without layout, fr'] = [ + FALSE, + 'my_entity_type', + 'my_entity_type.entity_without_layout.fr', + [], + ]; $data['empty value, empty defaults'] = [ FALSE, NULL,