diff --git a/composer.lock b/composer.lock index 601bdce4cd1..7548d2e5b8e 100644 --- a/composer.lock +++ b/composer.lock @@ -494,3 +494,3 @@ "name": "drupal/core", - "version": "10.4.3", + "version": "10.4.x-dev", "dist": { @@ -655,3 +655,3 @@ "name": "drupal/core-project-message", - "version": "10.4.3", + "version": "10.4.x-dev", "dist": { @@ -688,3 +688,3 @@ "name": "drupal/core-vendor-hardening", - "version": "10.4.3", + "version": "10.4.x-dev", "dist": { @@ -9994,3 +9994,7 @@ "minimum-stability": "dev", - "stability-flags": {}, + "stability-flags": { + "drupal/core": 20, + "drupal/core-project-message": 20, + "drupal/core-vendor-hardening": 20 + }, "prefer-stable": true, diff --git a/composer/Metapackage/CoreRecommended/composer.json b/composer/Metapackage/CoreRecommended/composer.json index 7fd9e959417..9f781b931cb 100644 --- a/composer/Metapackage/CoreRecommended/composer.json +++ b/composer/Metapackage/CoreRecommended/composer.json @@ -9,3 +9,3 @@ "require": { - "drupal/core": "10.4.3", + "drupal/core": "10.4.x-dev", "asm89/stack-cors": "~v2.2.0", diff --git a/composer/Metapackage/PinnedDevDependencies/composer.json b/composer/Metapackage/PinnedDevDependencies/composer.json index 241b74687c0..98564cebc4a 100644 --- a/composer/Metapackage/PinnedDevDependencies/composer.json +++ b/composer/Metapackage/PinnedDevDependencies/composer.json @@ -9,3 +9,3 @@ "require": { - "drupal/core": "10.4.3", + "drupal/core": "10.4.x-dev", "behat/mink": "v1.12.0", diff --git a/composer/Template/LegacyProject/composer.json b/composer/Template/LegacyProject/composer.json index 0921a0d2da8..508b5b36460 100644 --- a/composer/Template/LegacyProject/composer.json +++ b/composer/Template/LegacyProject/composer.json @@ -29,3 +29,3 @@ }, - "minimum-stability": "stable", + "minimum-stability": "dev", "prefer-stable": true, diff --git a/composer/Template/RecommendedProject/composer.json b/composer/Template/RecommendedProject/composer.json index b91df479dd1..032ef9ff94b 100644 --- a/composer/Template/RecommendedProject/composer.json +++ b/composer/Template/RecommendedProject/composer.json @@ -28,3 +28,3 @@ }, - "minimum-stability": "stable", + "minimum-stability": "dev", "prefer-stable": true, diff --git a/core/core.services.yml b/core/core.services.yml index 10feccb1372..be54cc82ecf 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -1859,4 +1859,2 @@ services: arguments: ['@current_user', '@path.current', '@path.matcher', '@language_manager'] - response_filter.rss.cdata: - class: Drupal\Core\EventSubscriber\RssResponseCdata response_filter.rss.relative_url: diff --git a/core/lib/Drupal.php b/core/lib/Drupal.php index 3eea5787fb5..4c404aa76e6 100644 --- a/core/lib/Drupal.php +++ b/core/lib/Drupal.php @@ -77,3 +77,3 @@ class Drupal { */ - const VERSION = '10.4.3'; + const VERSION = '10.4.4-dev'; diff --git a/core/lib/Drupal/Component/Annotation/composer.json b/core/lib/Drupal/Component/Annotation/composer.json index a8769629a58..697c06f0109 100644 --- a/core/lib/Drupal/Component/Annotation/composer.json +++ b/core/lib/Drupal/Component/Annotation/composer.json @@ -11,6 +11,6 @@ "doctrine/annotations": "^1.14", - "drupal/core-class-finder": "^10.4", - "drupal/core-file-cache": "^10.4", - "drupal/core-plugin": "^10.4", - "drupal/core-utility": "^10.4" + "drupal/core-class-finder": "10.4.x-dev", + "drupal/core-file-cache": "10.4.x-dev", + "drupal/core-plugin": "10.4.x-dev", + "drupal/core-utility": "10.4.x-dev" }, diff --git a/core/lib/Drupal/Component/Datetime/composer.json b/core/lib/Drupal/Component/Datetime/composer.json index f4d8b7125af..e35cc2cda1a 100644 --- a/core/lib/Drupal/Component/Datetime/composer.json +++ b/core/lib/Drupal/Component/Datetime/composer.json @@ -10,3 +10,3 @@ "php": ">=8.1.0", - "drupal/core-utility": "^10.4" + "drupal/core-utility": "10.4.x-dev" }, diff --git a/core/lib/Drupal/Component/Discovery/composer.json b/core/lib/Drupal/Component/Discovery/composer.json index b241b980dc1..34d34067c17 100644 --- a/core/lib/Drupal/Component/Discovery/composer.json +++ b/core/lib/Drupal/Component/Discovery/composer.json @@ -10,4 +10,4 @@ "php": ">=8.1.0", - "drupal/core-file-cache": "^10.4", - "drupal/core-serialization": "^10.4" + "drupal/core-file-cache": "10.4.x-dev", + "drupal/core-serialization": "10.4.x-dev" }, diff --git a/core/lib/Drupal/Component/FrontMatter/composer.json b/core/lib/Drupal/Component/FrontMatter/composer.json index 63d75e93ffc..3ac47f3785b 100644 --- a/core/lib/Drupal/Component/FrontMatter/composer.json +++ b/core/lib/Drupal/Component/FrontMatter/composer.json @@ -10,3 +10,3 @@ "php": ">=8.1.0", - "drupal/core-serialization": "^10.4" + "drupal/core-serialization": "10.4.x-dev" }, diff --git a/core/lib/Drupal/Component/Gettext/composer.json b/core/lib/Drupal/Component/Gettext/composer.json index 56c5d1d08cc..6b731b3c158 100644 --- a/core/lib/Drupal/Component/Gettext/composer.json +++ b/core/lib/Drupal/Component/Gettext/composer.json @@ -11,3 +11,3 @@ "php": ">=8.1.0", - "drupal/core-render": "^10.4" + "drupal/core-render": "10.4.x-dev" }, diff --git a/core/lib/Drupal/Component/PhpStorage/composer.json b/core/lib/Drupal/Component/PhpStorage/composer.json index 73c49b7a595..395e10d2435 100644 --- a/core/lib/Drupal/Component/PhpStorage/composer.json +++ b/core/lib/Drupal/Component/PhpStorage/composer.json @@ -10,3 +10,3 @@ "php": ">=8.1.0", - "drupal/core-file-security": "^10.4" + "drupal/core-file-security": "10.4.x-dev" }, diff --git a/core/lib/Drupal/Component/Render/composer.json b/core/lib/Drupal/Component/Render/composer.json index df2bcf5f4cc..72c0060d266 100644 --- a/core/lib/Drupal/Component/Render/composer.json +++ b/core/lib/Drupal/Component/Render/composer.json @@ -10,3 +10,3 @@ "php": ">=8.1.0", - "drupal/core-utility": "^10.4" + "drupal/core-utility": "10.4.x-dev" }, diff --git a/core/lib/Drupal/Core/EventSubscriber/RssResponseCdata.php b/core/lib/Drupal/Core/EventSubscriber/RssResponseCdata.php deleted file mode 100644 index 4c3c88726d0..00000000000 --- a/core/lib/Drupal/Core/EventSubscriber/RssResponseCdata.php +++ /dev/null @@ -1,79 +0,0 @@ -getResponse()->headers->get('Content-Type', ''), 'application/rss+xml') === FALSE) { - return; - } - - $response = $event->getResponse(); - $response->setContent($this->wrapDescriptionCdata($response->getContent())); - } - - /** - * Converts description node to CDATA RSS markup. - * - * @param string $rss_markup - * The RSS markup to update. - * - * @return string|false - * The updated RSS XML or FALSE if there is an error saving the xml. - */ - protected function wrapDescriptionCdata(string $rss_markup): string|false { - $rss_dom = new \DOMDocument(); - - // Load the RSS, if there are parsing errors, abort and return the unchanged - // markup. - $previous_value = libxml_use_internal_errors(TRUE); - $rss_dom->loadXML($rss_markup); - $errors = libxml_get_errors(); - libxml_use_internal_errors($previous_value); - if ($errors) { - return $rss_markup; - } - - foreach ($rss_dom->getElementsByTagName('item') as $item) { - foreach ($item->getElementsByTagName('description') as $node) { - $html_markup = $node->nodeValue; - if (!empty($html_markup)) { - $html_markup = Xss::filter($html_markup, ['a', 'abbr', 'acronym', 'address', 'b', 'bdo', 'big', 'blockquote', 'br', 'caption', 'cite', 'code', 'col', 'colgroup', 'dd', 'del', 'dfn', 'dl', 'dt', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'ins', 'kbd', 'li', 'ol', 'p', 'pre', 'q', 'samp', 'small', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'tt', 'ul', 'var']); - $new_node = $rss_dom->createCDATASection($html_markup); - $node->replaceChild($new_node, $node->firstChild); - } - } - } - - return $rss_dom->saveXML(); - } - - /** - * {@inheritdoc} - */ - public static function getSubscribedEvents(): array { - // This should run after any other response subscriber that modifies the - // markup. - // @see \Drupal\Core\EventSubscriber\RssResponseRelativeUrlFilter - $events[KernelEvents::RESPONSE][] = ['onResponse', -513]; - - return $events; - } - -} diff --git a/core/lib/Drupal/Core/EventSubscriber/RssResponseRelativeUrlFilter.php b/core/lib/Drupal/Core/EventSubscriber/RssResponseRelativeUrlFilter.php index 204a5780428..fa6c09c7a7d 100644 --- a/core/lib/Drupal/Core/EventSubscriber/RssResponseRelativeUrlFilter.php +++ b/core/lib/Drupal/Core/EventSubscriber/RssResponseRelativeUrlFilter.php @@ -76,3 +76,2 @@ public static function getSubscribedEvents(): array { // Should run after any other response subscriber that modifies the markup. - // Only the CDATA wrapper should run after this filter. // @see \Drupal\Core\EventSubscriber\ActiveLinkResponseFilter diff --git a/core/misc/cspell/dictionary.txt b/core/misc/cspell/dictionary.txt index 0caf62d7351..67f7e4338b2 100644 --- a/core/misc/cspell/dictionary.txt +++ b/core/misc/cspell/dictionary.txt @@ -191,2 +191,3 @@ drupalmediatoolbar drupalorg +eacute editables @@ -230,2 +231,3 @@ fieldapi fieldblock +fieldbody fieldgroup @@ -234,2 +236,3 @@ fieldlayout fieldnames +fieldnid fieldsets diff --git a/core/modules/layout_builder/css/layout-builder.css b/core/modules/layout_builder/css/layout-builder.css index 0e886518aba..d211de2e5fd 100644 --- a/core/modules/layout_builder/css/layout-builder.css +++ b/core/modules/layout_builder/css/layout-builder.css @@ -180 +180,5 @@ } + +#drupal-off-canvas .configured-conditions { + margin: 0.5em 0 1.5em; +} diff --git a/core/modules/layout_builder/css/layout-builder.pcss.css b/core/modules/layout_builder/css/layout-builder.pcss.css index 7ddb9543e00..cd3eebbb2f7 100644 --- a/core/modules/layout_builder/css/layout-builder.pcss.css +++ b/core/modules/layout_builder/css/layout-builder.pcss.css @@ -168 +168,4 @@ } +#drupal-off-canvas .configured-conditions { + margin: 0.5em 0 1.5em; +} diff --git a/core/modules/layout_builder/layout_builder.links.contextual.yml b/core/modules/layout_builder/layout_builder.links.contextual.yml index 4bbfcc9e64d..60c3da0f28f 100644 --- a/core/modules/layout_builder/layout_builder.links.contextual.yml +++ b/core/modules/layout_builder/layout_builder.links.contextual.yml @@ -29 +29,11 @@ layout_builder_block_remove: data-dialog-renderer: off_canvas + +layout_builder_block_visibility: + title: 'Control visibility' + route_name: 'layout_builder.visibility' + group: 'layout_builder_block' + options: + attributes: + class: ['use-ajax'] + data-dialog-type: dialog + data-dialog-renderer: off_canvas diff --git a/core/modules/layout_builder/layout_builder.routing.yml b/core/modules/layout_builder/layout_builder.routing.yml index fa72dcec931..8c6e067c4d0 100644 --- a/core/modules/layout_builder/layout_builder.routing.yml +++ b/core/modules/layout_builder/layout_builder.routing.yml @@ -147 +147,40 @@ layout_builder.move_block: layout_builder_tempstore: TRUE + +layout_builder.visibility: + path: '/layout_builder/visibility/block/{section_storage_type}/{section_storage}/{delta}/{uuid}' + defaults: + _form: '\Drupal\layout_builder\Form\BlockVisibilityForm' + _title_callback: '\Drupal\layout_builder\Form\BlockVisibilityForm::title' + requirements: + _layout_builder_access: 'view' + options: + _admin_route: TRUE + parameters: + section_storage: + layout_builder_tempstore: TRUE + +layout_builder.add_visibility: + path: '/layout_builder/visibility/block/{section_storage_type}/{section_storage}/{delta}/{uuid}/{plugin_id}' + defaults: + _form: '\Drupal\layout_builder\Form\ConfigureVisibilityForm' + _title_callback: '\Drupal\layout_builder\Form\ConfigureVisibilityForm::title' + requirements: + _layout_builder_access: 'view' + options: + _admin_route: TRUE + parameters: + section_storage: + layout_builder_tempstore: TRUE + +layout_builder.delete_visibility: + path: '/layout_builder/visibility/block/{section_storage_type}/{section_storage}/{delta}/{uuid}/{plugin_id}/delete' + defaults: + _form: '\Drupal\layout_builder\Form\DeleteVisibilityForm' + _title_callback: '\Drupal\layout_builder\Form\DeleteVisibilityForm::title' + requirements: + _layout_builder_access: 'view' + options: + _admin_route: TRUE + parameters: + section_storage: + layout_builder_tempstore: TRUE diff --git a/core/modules/layout_builder/layout_builder.services.yml b/core/modules/layout_builder/layout_builder.services.yml index 65c93652209..20efd491984 100644 --- a/core/modules/layout_builder/layout_builder.services.yml +++ b/core/modules/layout_builder/layout_builder.services.yml @@ -59,2 +59,7 @@ services: Drupal\layout_builder\InlineBlockUsageInterface: '@inline_block.usage' + layout_builder.component_visibility_subscriber: + class: Drupal\layout_builder\EventSubscriber\SectionComponentVisibility + arguments: ['@context.handler', '@plugin.manager.condition'] + tags: + - { name: event_subscriber } layout_builder.controller.entity_form: diff --git a/core/modules/layout_builder/src/Element/LayoutBuilder.php b/core/modules/layout_builder/src/Element/LayoutBuilder.php index 2676d5a0dba..b38dc54b772 100644 --- a/core/modules/layout_builder/src/Element/LayoutBuilder.php +++ b/core/modules/layout_builder/src/Element/LayoutBuilder.php @@ -252,3 +252,3 @@ protected function buildAdministrativeSection(SectionStorageInterface $section_s 'metadata' => [ - 'operations' => 'move:update:remove', + 'operations' => 'move:update:remove:visibility', ], diff --git a/core/modules/layout_builder/src/EventSubscriber/SectionComponentVisibility.php b/core/modules/layout_builder/src/EventSubscriber/SectionComponentVisibility.php new file mode 100644 index 00000000000..1734f4e69d2 --- /dev/null +++ b/core/modules/layout_builder/src/EventSubscriber/SectionComponentVisibility.php @@ -0,0 +1,88 @@ +contextHandler = $context_handler; + $this->conditionManager = $condition_manager; + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + // Priority is set to 255 so this subscriber is run after the one in + // BlockComponentRenderArray. + $events[LayoutBuilderEvents::SECTION_COMPONENT_BUILD_RENDER_ARRAY] = ['onBuildRender', 255]; + return $events; + } + + /** + * Determines the visibility of section components. + * + * @param \Drupal\layout_builder\Event\SectionComponentBuildRenderArrayEvent $event + * The section component build render array event. + */ + public function onBuildRender(SectionComponentBuildRenderArrayEvent $event) { + if ($event->inPreview()) { + return; + } + + $conditions = []; + + $visibility = $event->getComponent()->get('visibility') ?: []; + foreach ($visibility as $uuid => $configuration) { + $condition = $this->conditionManager->createInstance($configuration['id'], $configuration); + if ($condition instanceof ContextAwarePluginInterface) { + $this->contextHandler->applyContextMapping($condition, $event->getContexts()); + } + $event->addCacheableDependency($condition); + $conditions[$uuid] = $condition; + } + + $visibility_operator = $event->getComponent()->get('visibility_operator') ?: 'and'; + + if ($conditions && !$this->resolveConditions($conditions, $visibility_operator)) { + // If conditions do not resolve, do not process other subscribers. + $event->stopPropagation(); + } + } + +} diff --git a/core/modules/layout_builder/src/Form/BlockVisibilityForm.php b/core/modules/layout_builder/src/Form/BlockVisibilityForm.php new file mode 100644 index 00000000000..8f0036ba67e --- /dev/null +++ b/core/modules/layout_builder/src/Form/BlockVisibilityForm.php @@ -0,0 +1,462 @@ +conditionManager = $condition_manager; + $this->formBuilder = $form_builder; + $this->layoutTempstoreRepository = $layout_tempstore_repository; + $this->httpKernel = $http_kernel; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('plugin.manager.condition'), + $container->get('form_builder'), + $container->get('layout_builder.tempstore_repository'), + $container->get('http_kernel') + ); + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'layout_builder_block_visibility'; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state, ?SectionStorageInterface $section_storage = NULL, $delta = NULL, $uuid = NULL) { + $this->sectionStorage = $section_storage; + $this->delta = $delta; + $this->uuid = $uuid; + + // Any visibility conditions that have already been added to the block. + $visibility_conditions_applied_to_block = $this->getCurrentComponent()->get('visibility') ?: []; + + // Visibility condition types that can be added to a block. + $conditions_available_to_block = []; + foreach ($this->conditionManager->getFilteredDefinitions('layout_builder', $this->getPopulatedContexts($section_storage)) as $plugin_id => $definition) { + $conditions_available_to_block[$plugin_id] = $definition['label']; + } + + $items = []; + foreach ($visibility_conditions_applied_to_block as $visibility_id => $configuration) { + /** @var \Drupal\Core\Condition\ConditionInterface $condition */ + $condition = $this->conditionManager->createInstance($configuration['id'], $configuration); + $options = [ + 'attributes' => [ + 'class' => ['use-ajax'], + 'data-dialog-type' => 'dialog', + 'data-dialog-renderer' => 'off_canvas', + 'data-outside-in-edit' => TRUE, + ], + ]; + $items[$visibility_id] = [ + 'label' => [ + 'data' => [ + 'condition_name' => [ + '#type' => 'html_tag', + '#tag' => 'b', + '#value' => $condition->getPluginId(), + ], + 'condition_summary' => [ + '#type' => 'container', + '#markup' => $condition->summary(), + ], + ], + ], + 'edit' => [ + 'data' => [ + '#type' => 'link', + '#title' => $this->t('Edit'), + '#url' => Url::fromRoute('layout_builder.add_visibility', $this->getParameters($visibility_id), $options), + ], + ], + 'delete' => [ + 'data' => [ + '#type' => 'link', + '#title' => $this->t('Delete'), + '#url' => Url::fromRoute('layout_builder.delete_visibility', $this->getParameters($visibility_id), $options), + ], + ], + ]; + } + + if ($items) { + $form['visibility'] = [ + '#prefix' => '
', + '#suffix' => '
', + '#theme' => 'table', + '#rows' => $items, + '#caption' => $this->t('Configured Conditions'), + '#weight' => 10, + ]; + } + + $form['condition'] = [ + '#type' => 'select', + '#title' => $this->t('Add a visibility condition'), + '#options' => $conditions_available_to_block, + '#empty_value' => '', + '#weight' => 20, + ]; + + // Determines if multiple conditions should be applied with 'and' or 'or'. + $form['operator'] = [ + '#type' => 'radios', + '#title' => $this->t('Operator'), + '#options' => [ + 'and' => $this->t('And'), + 'or' => $this->t('Or'), + ], + '#default_value' => $this->getCurrentComponent()->get('visibility_operator') ?: 'and', + // This field is not necessary until multiple conditions are added. + '#access' => count($items) > 0, + // If there are two or more visibility conditions, this field appears + // above the list of existing conditions. If there is only one visibility + // condition, and a second one is being added, then this field appears + // between the 'Add a visibility condition' dropdown and the submit + // button. + '#weight' => count($items) === 1 ? 30 : 3, + ]; + + // This is a submit button that only appears once two or more visibility + // conditions are present. This submit button appears so the user can + // update the visibility operator, a setting that impacts the entire block. + // This is different than the default submit button/handler for this form, + // which is used to add a visibility condition to the block. + $form['update_operator'] = [ + '#type' => 'submit', + '#access' => count($items) > 1, + '#weight' => 5, + '#value' => $this->t('Update operator'), + '#submit' => ['::updateOperator'], + ]; + + if (count($items) === 1) { + // If there is only one visibility condition, hide the operator field + // until a second condition is selected to be added to the block. + $form['operator']['#states'] = [ + 'invisible' => [ + '[name="condition"]' => ['value' => ''], + ], + ]; + } + + $form['actions']['#weight'] = 40; + $form['actions']['submit'] = [ + '#type' => 'submit', + '#value' => $this->t('Add condition'), + // Submit button is only visible if a condition is selected. + '#states' => [ + 'invisible' => [ + '[name="condition"]' => ['value' => ''], + ], + ], + ]; + + $form['actions']['settings_rebuild'] = [ + '#type' => 'hidden', + '#value' => $this->t('Condition settings rebuild'), + '#ajax' => [ + 'callback' => '::ajaxSettingsRebuild', + ], + ]; + + $form['#attributes']['data-layout-builder-target-highlight-id'] = $this->blockUpdateHighlightId($this->uuid); + + if ($this->isAjax()) { + $form['actions']['submit']['#ajax']['callback'] = '::ajaxSubmit'; + $form['actions']['submit']['#ajax']['event'] = 'click'; + $form['update_operator']['#ajax']['callback'] = '::ajaxSubmit'; + $form['update_operator']['#ajax']['event'] = 'click'; + + // Allow condition plugins settings forms use ajax elements with ajax form rebuild possibility. + // @see core/lib/Drupal/Core/Form/FormBuilder::buildForm():343 + if ($form_state->isMethodType('POST') && ($input = $form_state->getUserInput()) + && isset($input['_triggering_element_name']) && str_starts_with($input['_triggering_element_name'], 'settings[')) { + + // Make form API recognize & process ajax input submit that actually + // belong to another 'layout_builder_configure_visibility' form. + $form_state->setProcessInput(); + $form_state->setProgrammed(); + + $current_request = $this->getRequest(); + + $parameters = $this->getParameters($input['plugin_id']); + $parameters['ajax_form'] = TRUE; + $parameters['operator'] = $input['operator']; + + $url = new Url('layout_builder.add_visibility', $parameters); + + // Set all proxy submit input data so form API can actually process the form. + $subrequest_data = [ + '_drupal_ajax' => 1, + '_wrapper_format' => 'drupal_ajax', + 'ajax_form' => 1, + '_triggering_element_name' => $input['_triggering_element_name'], + 'form_build_id' => $input['form_build_id'], + 'form_token' => $input['form_token'], + 'form_id' => $input['form_id'], + 'condition' => $input['condition'], + 'settings' => $input['settings'], + 'operator' => $input['operator'], + ]; + + foreach (static::BYPASS_SUBREQUEST_DATA as $key) { + if ($current_request->request->has($key)) { + $subrequest_data += [ + $key => $current_request->request->all($key), + ]; + } + } + + // Make separate http subrequest to plugin settings form imitating real http request. + $subrequest = Request::create( + $url->toString(), + 'POST', + $subrequest_data, + $current_request->cookies->all(), + [], + $current_request->server->all() + ); + + // Grab ajax response from slave form & proxy it as host ajax response. + $response = $this->httpKernel->handle($subrequest, HttpKernelInterface::SUB_REQUEST); + + if (!$response instanceof AjaxResponse) { + throw new \UnexpectedValueException(sprintf( + 'Unexpected response of %s type for the condition form.', + get_class($response) + )); + } + + $form_state->setResponse($response); + $form_state->setTriggeringElement($form['actions']['settings_rebuild']); + + // Allow condition settings forms use ajax inputs with form rebuild logic. + // @see core/lib/Drupal/Core/Form/FormBuilder::buildForm():343 + $current_request->attributes->set('form_id', 'layout_builder_block_visibility'); + } + } + + return $form; + } + + /** + * {@inheritdoc} + */ + public function ajaxSettingsRebuild(array $form, FormStateInterface $form_state) { + if ($response = $form_state->getResponse()) { + return $response; + } + + return $form; + } + + /** + * {@inheritdoc} + */ + protected function successfulAjaxSubmit(array $form, FormStateInterface $form_state) { + $triggering_element = $form_state->getTriggeringElement(); + // If the submit was triggered by the "update operator" button, just + // rebuild the layout UI and close the dialog. + if (isset($triggering_element['#submit'][0]) && ($triggering_element['#submit'][0] === '::updateOperator')) { + return $this->rebuildAndClose($this->sectionStorage); + } + + // Adding a visibility condition to a block is a two step process. This + // submit handler is triggered after completion of step 1: choosing the + // condition to add. The logic below opens a configuration form for step 2: + // configuring the condition that was just added. + $condition = $form_state->getValue('condition'); + $parameters = $this->getParameters($condition); + + // Build the configuration form to be used in step 2. + $new_form = $this->formBuilder->getForm('\Drupal\layout_builder\Form\ConfigureVisibilityForm', $this->sectionStorage, $parameters['delta'], $parameters['uuid'], $parameters['plugin_id']); + + // @todo The changes to #action/actions need to be documented or refactored + // to better resemble other dual-dialog forms in Layout Builder. + $new_form['#action'] = (new Url('layout_builder.add_visibility', $parameters))->toString(); + $url = new Url('layout_builder.add_visibility', $parameters, ['query' => [FormBuilderInterface::AJAX_FORM_REQUEST => TRUE, '_wrapper_format' => 'drupal_ajax']]); + $new_form['actions']['submit']['#attached']['drupalSettings']['ajax'][$new_form['actions']['submit']['#id']]['url'] = $url->toString(); + $response = new AjaxResponse(); + $response->addCommand(new OpenOffCanvasDialogCommand($this->t('Configure condition'), $new_form)); + return $response; + } + + /** + * Submit handler for updating just the visibility operator. + * + * @param array $form + * The form array. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state object. + */ + public function updateOperator(array $form, FormStateInterface $form_state) { + $operator_value = $form_state->getValue('operator'); + $component = $this->getCurrentComponent(); + $component->set('visibility_operator', $operator_value); + $this->layoutTempstoreRepository->set($this->sectionStorage); + $form_state->setRedirectUrl($this->sectionStorage->getLayoutBuilderUrl()); + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $parameters = $this->getParameters($form_state->getValue('condition')); + $operator = $form_state->getValue('operator'); + $parameters['operator'] = $operator === 'or' ? $operator : 'and'; + $url = new Url('layout_builder.add_visibility', $parameters); + $response = new RedirectResponse($url->toString()); + $form_state->setResponse($response); + } + + /** + * Gets the parameters needed for the various Url() and form invocations. + * + * @param string $visibility_id + * The id of the visibility plugin. + * + * @return array + * List of Url parameters. + */ + protected function getParameters($visibility_id) { + return [ + 'section_storage_type' => $this->sectionStorage->getStorageType(), + 'section_storage' => $this->sectionStorage->getStorageId(), + 'delta' => $this->delta, + 'uuid' => $this->uuid, + 'plugin_id' => $visibility_id, + ]; + } + + /** + * Provides a title callback. + * + * @param \Drupal\layout_builder\SectionStorageInterface $section_storage + * The section storage. + * @param int $delta + * The original delta of the section. + * @param string $uuid + * The UUID of the block being updated. + * + * @return string + * The title for the block visibility form. + */ + public function title(SectionStorageInterface $section_storage, $delta, $uuid) { + $block_label = $section_storage + ->getSection($delta) + ->getComponent($uuid) + ->getPlugin() + ->label(); + + return $this->t('Configure visibility rules for the @block_label block', ['@block_label' => $block_label]); + } + +} diff --git a/core/modules/layout_builder/src/Form/ConfigureVisibilityForm.php b/core/modules/layout_builder/src/Form/ConfigureVisibilityForm.php new file mode 100644 index 00000000000..8bcd9070670 --- /dev/null +++ b/core/modules/layout_builder/src/Form/ConfigureVisibilityForm.php @@ -0,0 +1,341 @@ +layoutTempstoreRepository = $layout_tempstore_repository; + $this->conditionManager = $condition_manager; + $this->uuidGenerator = $uuid_generator; + $this->pluginFormFactory = $plugin_form_manager; + $this->classResolver = $class_resolver; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('layout_builder.tempstore_repository'), + $container->get('plugin.manager.condition'), + $container->get('uuid'), + $container->get('plugin_form.factory'), + $container->get('class_resolver') + ); + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'layout_builder_configure_visibility'; + } + + /** + * Prepares the condition plugin based on the condition ID. + * + * @param string $condition_id + * A condition UUID, or the plugin ID used to create a new condition. + * @param array $value + * The condition configuration. + * + * @return \Drupal\Core\Condition\ConditionInterface + * The condition plugin. + */ + protected function prepareCondition($condition_id, array $value) { + if ($value) { + return $this->conditionManager->createInstance($value['id'], $value); + } + /** @var \Drupal\Core\Condition\ConditionInterface $condition */ + $condition = $this->conditionManager->createInstance($condition_id); + $configuration = $condition->getConfiguration(); + $configuration['uuid'] = $this->uuidGenerator->generate(); + $condition->setConfiguration($configuration); + return $condition; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state, ?SectionStorageInterface $section_storage = NULL, $delta = NULL, $uuid = NULL, $plugin_id = NULL) { + $this->sectionStorage = $section_storage; + $this->delta = $delta; + $this->uuid = $uuid; + + $visibility_conditions = $this->getCurrentComponent()->get('visibility'); + $configuration = !empty($visibility_conditions[$plugin_id]) ? $visibility_conditions[$plugin_id] : []; + $this->configuration = $configuration; + $this->condition = $this->prepareCondition($plugin_id, $configuration); + + $form_state->setTemporaryValue('gathered_contexts', $this->getPopulatedContexts($section_storage)); + + $form['#tree'] = TRUE; + $form['settings'] = []; + $subform_state = SubformState::createForSubform($form['settings'], $form, $form_state); + $form['settings'] = $this->getConditionPluginForm($this->condition)->buildConfigurationForm($form['settings'], $subform_state); + $form['settings']['id'] = [ + '#type' => 'value', + '#value' => $this->condition->getPluginId(), + ]; + + $form['actions']['submit'] = [ + '#type' => 'submit', + '#value' => $configuration ? $this->t('Update') : $this->t('Add condition'), + '#button_type' => 'primary', + ]; + + // Used if condition form has ajax fields & requires rebuild from the parent form. + $form['plugin_id'] = [ + '#type' => 'hidden', + '#value' => $plugin_id, + ]; + + // If one is not already present, add a hidden field with the value of + // the operator field from BlockVisibilityForm - the form that precedes + // this one when adding/updating a visibility condition. + if (!$form_state->getValue('operator')) { + // Get the input values from the form that preceded this one. + $user_input = $form_state->getUserInput(); + $form['operator']['#type'] = 'hidden'; + if (isset($user_input['operator']) && $user_input['operator'] === 'or') { + $form['operator']['#value'] = $user_input['operator']; + } + else { + $form['operator']['#value'] = 'and'; + } + } + + $form['back_button'] = [ + '#type' => 'link', + '#url' => Url::fromRoute('layout_builder.visibility', + [ + 'section_storage_type' => $section_storage->getStorageType(), + 'section_storage' => $section_storage->getStorageId(), + 'delta' => $delta, + 'uuid' => $uuid, + ] + ), + '#title' => $this->t('Back'), + ]; + + $form['#attributes']['data-layout-builder-target-highlight-id'] = $this->blockUpdateHighlightId($this->uuid); + + if ($this->isAjax()) { + $form['actions']['submit']['#ajax']['callback'] = '::ajaxSubmit'; + $form['back_button']['#attributes'] = [ + 'class' => ['use-ajax'], + 'data-dialog-type' => 'dialog', + 'data-dialog-renderer' => 'off_canvas', + ]; + + $input = $form_state->getUserInput(); + + // Process form as ajax submit. + if (!empty($input['settings']) && !empty($input['_triggering_element_name']) + && $this->getRequest()->get('ajax_form')) { + $this->getRequest()->query->set('ajax_form', TRUE); + } + } + + return $form; + } + + /** + * {@inheritdoc} + */ + public function validateForm(array &$form, FormStateInterface $form_state) { + $subform_state = SubformState::createForSubform($form['settings'], $form, $form_state); + $this->getConditionPluginForm($this->condition)->validateConfigurationForm($form['settings'], $subform_state); + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + // Call the plugin submit handler. + $subform_state = SubformState::createForSubform($form['settings'], $form, $form_state); + $this->getConditionPluginForm($this->condition)->submitConfigurationForm($form, $subform_state); + + // If this block is context-aware, set the context mapping. + if ($this->condition instanceof ContextAwarePluginInterface) { + $this->condition->setContextMapping($subform_state->getValue('context_mapping', [])); + } + + $configuration = $this->condition->getConfiguration(); + + $component = $this->getCurrentComponent(); + $visibility_conditions = $component->get('visibility'); + $visibility_conditions[$configuration['uuid']] = $configuration; + $component->set('visibility', $visibility_conditions); + $component->set('visibility_operator', $form_state->getValue('operator')); + + $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); + } + + /** + * Retrieves the plugin form for a given condition. + * + * @param \Drupal\Core\Condition\ConditionInterface $condition + * The condition plugin. + * + * @return \Drupal\Core\Plugin\PluginFormInterface + * The plugin form for the condition. + */ + protected function getConditionPluginForm(ConditionInterface $condition) { + if ($condition instanceof PluginWithFormsInterface) { + return $this->pluginFormFactory->createInstance($condition, 'configure'); + } + return $condition; + } + + /** + * Provides a title callback. + * + * @param \Drupal\layout_builder\SectionStorageInterface $section_storage + * The section storage. + * @param int $delta + * The original delta of the section. + * @param string $uuid + * The UUID of the block being updated. + * + * @return string + * The title for the block visibility form. + */ + public function title(SectionStorageInterface $section_storage, $delta, $uuid) { + $block_label = $section_storage + ->getSection($delta) + ->getComponent($uuid) + ->getPlugin() + ->label(); + + return $this->t('Configure visibility rule for the @block_label block', ['@block_label' => $block_label]); + } + +} diff --git a/core/modules/layout_builder/src/Form/DeleteVisibilityForm.php b/core/modules/layout_builder/src/Form/DeleteVisibilityForm.php new file mode 100644 index 00000000000..c0c9b25f047 --- /dev/null +++ b/core/modules/layout_builder/src/Form/DeleteVisibilityForm.php @@ -0,0 +1,205 @@ +layoutTempstoreRepository = $layout_tempstore_repository; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('layout_builder.tempstore_repository') + ); + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state, ?SectionStorageInterface $section_storage = NULL, $delta = NULL, $uuid = NULL, $plugin_id = NULL) { + $this->sectionStorage = $section_storage; + $this->delta = $delta; + $this->uuid = $uuid; + $this->pluginId = $plugin_id; + $form = parent::buildForm($form, $form_state); + $form['actions']['cancel'] = $this->buildCancelLink(); + $form['#attributes']['data-layout-builder-target-highlight-id'] = $this->blockUpdateHighlightId($this->uuid); + if ($this->isAjax()) { + $form['actions']['submit']['#ajax']['callback'] = '::ajaxSubmit'; + } + return $form; + } + + /** + * {@inheritdoc} + */ + public function getQuestion() { + return $this->t('Are you sure you want to delete this visibility condition?'); + } + + /** + * {@inheritdoc} + */ + public function getCancelUrl() { + $parameters = $this->getParameters(); + return new Url('layout_builder.visibility', $parameters); + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'layout_builder_delete_visibility'; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $component = $this->getCurrentComponent(); + $visibility_conditions = $component->get('visibility'); + unset($visibility_conditions[$this->pluginId]); + $component->set('visibility', $visibility_conditions); + $this->layoutTempstoreRepository->set($this->sectionStorage); + $form_state->setRedirectUrl($this->sectionStorage->getLayoutBuilderUrl()); + } + + /** + * Build a cancel button for the confirm form. + */ + protected function buildCancelLink() { + return [ + '#type' => 'button', + '#value' => $this->getCancelText(), + '#ajax' => [ + 'callback' => '::ajaxCancel', + ], + ]; + } + + /** + * Provides an ajax callback for the cancel button. + */ + public function ajaxCancel(array &$form, FormStateInterface $form_state) { + $parameters = $this->getParameters(); + $new_form = \Drupal::formBuilder()->getForm(BlockVisibilityForm::class, $this->sectionStorage, $parameters['delta'], $parameters['uuid']); + $new_form['#action'] = $this->getCancelUrl()->toString(); + $response = new AjaxResponse(); + $response->addCommand(new OpenOffCanvasDialogCommand($this->t('Delete condition'), $new_form)); + return $response; + } + + /** + * Gets the parameters needed for the various Url() and form invocations. + * + * @return array + * List of Url parameters. + */ + protected function getParameters() { + return [ + 'section_storage_type' => $this->sectionStorage->getStorageType(), + 'section_storage' => $this->sectionStorage->getStorageId(), + 'delta' => $this->delta, + 'uuid' => $this->uuid, + ]; + } + + /** + * Provides a title callback. + * + * @param \Drupal\layout_builder\SectionStorageInterface $section_storage + * The section storage. + * @param int $delta + * The original delta of the section. + * @param string $uuid + * The UUID of the block being updated. + * + * @return string + * The title for the block visibility form. + */ + public function title(SectionStorageInterface $section_storage, $delta, $uuid) { + $block_label = $section_storage + ->getSection($delta) + ->getComponent($uuid) + ->getPlugin() + ->label(); + + return $this->t('Delete visibility rule for the @block_label block', ['@block_label' => $block_label]); + } + + protected function successfulAjaxSubmit(array $form, FormStateInterface $form_state) { + return $this->rebuildAndClose($this->sectionStorage); + } + +} diff --git a/core/modules/layout_builder/src/SectionComponentTrait.php b/core/modules/layout_builder/src/SectionComponentTrait.php new file mode 100644 index 00000000000..06668c0b51e --- /dev/null +++ b/core/modules/layout_builder/src/SectionComponentTrait.php @@ -0,0 +1,51 @@ +sectionStorage->getSection($this->delta); + } + + /** + * Retrieves the current component being edited by the form. + * + * @return \Drupal\layout_builder\SectionComponent + * The current section component. + */ + public function getCurrentComponent() { + return $this->getCurrentSection()->getComponent($this->uuid); + } + +} diff --git a/core/modules/layout_builder/tests/src/FunctionalJavascript/BlockVisibilityTest.php b/core/modules/layout_builder/tests/src/FunctionalJavascript/BlockVisibilityTest.php new file mode 100644 index 00000000000..c2b6944c725 --- /dev/null +++ b/core/modules/layout_builder/tests/src/FunctionalJavascript/BlockVisibilityTest.php @@ -0,0 +1,323 @@ +createContentType(['type' => 'bundle_with_section_field']); + + $this->drupalLogin($this->drupalCreateUser([ + 'configure any layout', + 'create and edit custom blocks', + 'administer node display', + 'administer node fields', + 'access contextual links', + ])); + + // Enable layout builder. + $this->drupalGet(static::FIELD_UI_PREFIX . '/display/default'); + $this->submitForm( + ['layout[enabled]' => TRUE], + 'Save' + ); + + $this->createNode([ + 'type' => 'bundle_with_section_field', + 'body' => [ + [ + 'value' => 'The node body', + ], + ], + ])->save(); + + $this->createNode([ + 'type' => 'bundle_with_section_field', + 'body' => [ + [ + 'value' => 'The node body', + ], + ], + ])->save(); + } + + /** + * Tests conditional visibility. + */ + public function testConditionalVisibility() { + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + + // Remove all of the sections from the page. + $this->drupalGet(static::FIELD_UI_PREFIX . '/display/default/layout'); + + $page->clickLink('Remove Section 1'); + $assert_session->assertWaitOnAjaxRequest(); + $page->pressButton('Remove'); + $assert_session->assertWaitOnAjaxRequest(); + // Assert that there are no sections on the page. + $assert_session->pageTextNotContains('Remove Section 1'); + $assert_session->pageTextNotContains('Add block'); + + $page->clickLink('Add section'); + $assert_session->assertWaitOnAjaxRequest(); + $this->assertNotEmpty($assert_session->waitForElementVisible('css', '#drupal-off-canvas')); + + $page->clickLink('Three column'); + $assert_session->assertWaitOnAjaxRequest(); + $this->assertNotEmpty($assert_session->waitForElementVisible('css', '.layout-builder-configure-section')); + $page->pressButton('Add section'); + + $blocks_in_layout = [ + [ + 'label' => 'ID', + 'region_selector' => '.layout__region--first', + 'rendered_block_selector' => '.block-field-blocknodebundle-with-section-fieldnid', + ], + [ + 'label' => 'Powered by Drupal', + 'region_selector' => '.layout__region--second', + 'rendered_block_selector' => '.block-system-powered-by-block', + ], + [ + 'label' => 'Body', + 'region_selector' => '.layout__region--third', + 'rendered_block_selector' => '.block-field-blocknodebundle-with-section-fieldbody', + ], + ]; + + foreach ($blocks_in_layout as $block) { + $rendered_block_selector = $block['rendered_block_selector']; + $this->addBlock($block['label'], $block['region_selector'], "#layout-builder $rendered_block_selector"); + } + + $page->pressButton('Save layout'); + + foreach (['node/1', 'node/2'] as $path) { + $this->drupalGet($path); + foreach ($blocks_in_layout as $block) { + $this->assertSession()->elementExists('css', $block['rendered_block_selector']); + } + $assert_session->pageTextContains('The node body'); + } + + $this->drupalGet(static::FIELD_UI_PREFIX . '/display/default/layout'); + + // Confirm "Control visibility" contextual links available on each block. + foreach ($blocks_in_layout as $block) { + $rendered_block_selector = $block['rendered_block_selector']; + $block_element = $page->find('css', "#layout-builder $rendered_block_selector"); + $block_element->hasLink('Control visibility'); + } + + // Test Request Path visibility rule. + $this->beginAddCondition('Request Path'); + $page->checkField('settings[negate]'); + $page->findField('settings[pages]')->setValue('/node/2'); + $page->pressButton('Add condition'); + $assert_session->assertWaitOnAjaxRequest(); + $page->pressButton('Save layout'); + $this->drupalGet('node/1'); + $assert_session->pageTextContains('The node body'); + $this->drupalGet('node/2'); + $assert_session->pageTextNotContains('The node body'); + + // Confirm that editing an existing condition works. + $this->drupalGet(static::FIELD_UI_PREFIX . '/display/default/layout'); + $this->clickContextualLink(static::BODY_FIELDBLOCK_SELECTOR, 'Control visibility'); + $assert_session->assertWaitOnAjaxRequest(); + $this->assertNotEmpty($assert_session->waitForElementVisible('css', '#drupal-off-canvas')); + $page->clickLink('Edit'); + $assert_session->assertWaitOnAjaxRequest(); + $this->assertNotEmpty($assert_session->waitForElementVisible('css', '[value="Update"]')); + $page->findField('settings[pages]')->setValue('/node/1'); + $page->pressButton('Update'); + $assert_session->assertWaitOnAjaxRequest(); + $page->pressButton('Save layout'); + $this->drupalGet('node/1'); + $assert_session->pageTextNotContains('The node body'); + $this->drupalGet('node/2'); + $assert_session->pageTextContains('The node body'); + + // Confirm 'or' operator works ('and' is the default operator) + $this->beginAddCondition('Request Path'); + $page->checkField('settings[negate]'); + $page->findField('settings[pages]')->setValue('/node/2'); + $page->pressButton('Add condition'); + $assert_session->assertWaitOnAjaxRequest(); + $this->clickContextualLink(static::BODY_FIELDBLOCK_SELECTOR, 'Control visibility'); + $assert_session->assertWaitOnAjaxRequest(); + $page->findField('operator')->setValue('or'); + $page->pressButton('Update operator'); + $assert_session->assertWaitOnAjaxRequest(); + $page->pressButton('Save layout'); + $this->drupalGet('node/1'); + $assert_session->pageTextContains('The node body'); + $this->drupalGet('node/2'); + $assert_session->pageTextContains('The node body'); + + // Test Current Theme visibility rule. + $this->removeVisibilityConditions(); + $this->beginAddCondition('Current Theme'); + $page->checkField('settings[negate]'); + $page->findField('settings[theme]')->setValue('olivero'); + $page->pressButton('Add condition'); + $assert_session->assertWaitOnAjaxRequest(); + $page->pressButton('Save layout'); + $this->drupalGet('node/1'); + $assert_session->pageTextNotContains('The node body'); + $this->drupalGet('node/2'); + $assert_session->pageTextNotContains('The node body'); + + // Test User Role visibility rule. + $this->removeVisibilityConditions(); + $this->beginAddCondition('User Role'); + $page->checkField('settings[negate]'); + $page->checkField('settings[roles][anonymous]'); + $page->pressButton('Add condition'); + $assert_session->assertWaitOnAjaxRequest(); + $page->pressButton('Save layout'); + $this->drupalGet('node/1'); + $assert_session->pageTextContains('The node body'); + $this->drupalGet('node/2'); + $assert_session->pageTextContains('The node body'); + + $this->drupalLogout(); + + $this->drupalGet('node/1'); + $assert_session->pageTextNotContains('The node body'); + $this->drupalGet('node/2'); + $assert_session->pageTextNotContains('The node body'); + } + + /** + * Begins adding a visibility condition to the body field block. + * + * @param string $condition + * The visibility condition to add. + */ + protected function beginAddCondition($condition) { + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + + $this->drupalGet(static::FIELD_UI_PREFIX . '/display/default/layout'); + $this->clickContextualLink(static::BODY_FIELDBLOCK_SELECTOR, 'Control visibility'); + $assert_session->assertWaitOnAjaxRequest(); + $this->assertNotEmpty($assert_session->waitForElementVisible('css', '#drupal-off-canvas')); + $page->pressButton('Add a visibility condition'); + $page->clickLink($condition); + $this->assertNotEmpty($assert_session->waitForElementVisible('css', '[value="Add condition"]')); + $this->assertNotEmpty($assert_session->waitForElementVisible('css', '[name="settings[negate]"]')); + } + + /** + * Removes the visibility rules from the body field block. + */ + protected function removeVisibilityConditions() { + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + + $this->drupalGet(static::FIELD_UI_PREFIX . '/display/default/layout'); + $this->clickContextualLink(static::BODY_FIELDBLOCK_SELECTOR, 'Control visibility'); + $assert_session->assertWaitOnAjaxRequest(); + $this->assertNotEmpty($assert_session->waitForElementVisible('css', '#drupal-off-canvas')); + $page->clickLink('Delete'); + $assert_session->assertWaitOnAjaxRequest(); + $this->assertNotEmpty($assert_session->waitForElementVisible('css', '[value="Confirm"]')); + $page->pressButton('Confirm'); + $assert_session->assertWaitOnAjaxRequest(); + + // If multiple conditions present, this method will need to run again. + $this->clickContextualLink(static::BODY_FIELDBLOCK_SELECTOR, 'Control visibility'); + $assert_session->assertWaitOnAjaxRequest(); + $close_button = $assert_session->waitForElementVisible('css', '[title="Close"]'); + if ($page->hasLink('Delete')) { + $close_button->click(); + $this->removeVisibilityConditions(); + } + else { + $page->pressButton('Save layout'); + $this->drupalGet('node/1'); + $assert_session->pageTextContains('The node body'); + $this->drupalGet('node/2'); + $assert_session->pageTextContains('The node body'); + } + } + + /** + * Adds a block in the Layout Builder. + * + * @param string $block_link_text + * The link text to add the block. + * @param string $region_selector + * The link text to add the block. + * @param string $rendered_locator + * The CSS locator to confirm the block was rendered. + */ + protected function addBlock($block_link_text, $region_selector, $rendered_locator) { + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + + // Add a new block. + $add_block_link_selector = "#layout-builder $region_selector a:contains('Add block')"; + $this->assertNotEmpty($assert_session->waitForElementVisible('css', $add_block_link_selector)); + + $add_block_link = $page->find('css', $add_block_link_selector); + $add_block_link->click(); + $this->assertNotEmpty($assert_session->waitForElementVisible('css', '#drupal-off-canvas')); + $assert_session->assertWaitOnAjaxRequest(); + $page->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(); + $page->pressButton('Add block'); + + // Wait for block form to be rendered in the Layout Builder. + $this->assertNotEmpty($assert_session->waitForElement('css', $rendered_locator)); + } + +} diff --git a/core/modules/layout_builder/tests/src/Unit/EventSubscriber/SectionComponentVisibilityTest.php b/core/modules/layout_builder/tests/src/Unit/EventSubscriber/SectionComponentVisibilityTest.php new file mode 100644 index 00000000000..8009c50a574 --- /dev/null +++ b/core/modules/layout_builder/tests/src/Unit/EventSubscriber/SectionComponentVisibilityTest.php @@ -0,0 +1,238 @@ +prophesize(TypedDataManager::class); + $container = new ContainerBuilder(); + $container->set('typed_data_manager', $typed_data_manager->reveal()); + \Drupal::setContainer($container); + + $this->conditionManager = new ConditionManager( + new \ArrayIterator([]), + new NullBackend('null'), + $this->prophesize(ModuleHandlerInterface::class)->reveal() + ); + } + + /** + * Ensure that nothing happens when previewing. + * + * @covers ::onBuildRender + */ + public function testOnBuildRenderPreview() { + $subscriber = new SectionComponentVisibility( + new ContextHandler(), + $this->conditionManager + ); + $event = $this->prophesize(SectionComponentBuildRenderArrayEvent::class); + // Assert that when not in preview, the event is ignored. We do this by + // asserting that in preview is called and nothing else. + $event->inPreview()->shouldBeCalled(); + + $component = new SectionComponent('', 'top'); + $event->getComponent()->willReturn($component); + $subscriber->onBuildRender($event->reveal()); + } + + /** + * Ensure no conditions are applied when visibility isn't set. + * + * @covers ::onBuildRender + */ + public function testOnBuildRenderNonPreviewEmpty() { + $subscriber = new SectionComponentVisibility( + new ContextHandler(), + $this->conditionManager + ); + $event = $this->prophesize(SectionComponentBuildRenderArrayEvent::class); + // We're not in a preview. + $event->inPreview()->willReturn(FALSE); + + $component = new SectionComponent('', 'top'); + $event->getComponent()->willReturn($component); + $subscriber->onBuildRender($event->reveal()); + } + + /** + * Ensure no conditions are applied when visibility isn't set. + * + * @covers ::onBuildRender + */ + public function testOnBuildRenderNonPreviewBadPlugin() { + $subscriber = new SectionComponentVisibility( + new ContextHandler(), + $this->conditionManager + ); + $event = $this->prophesize(SectionComponentBuildRenderArrayEvent::class); + // We're not in a preview. + $event->inPreview()->willReturn(FALSE); + + // Build a component so we can set properties. + $component = new SectionComponent('', 'top'); + $component->set('visibility', [ + 'uuid' => ['id' => 'plugin_dne'], + ]); + $event->getComponent()->willReturn($component); + + $this->expectException(PluginNotFoundException::class); + $this->expectExceptionMessage('The "plugin_dne" plugin does not exist.'); + $subscriber->onBuildRender($event->reveal()); + } + + /** + * Ensure context aware plugins get their context applied. + * + * @covers ::onBuildRender + */ + public function testOnBuildRenderNonPreviewResolveContextAware() { + // Mock context aware plugin that will be used to assert we receive context. + $context_aware_plugin = $this->prophesize(ConditionInterface::class) + ->willImplement(ContextAwarePluginInterface::class); + + // Set our mock condition manager to return the context plugin. + $condition_manager = $this->prophesize(ConditionManager::class); + $condition_manager->createInstance('context_aware', ['id' => 'context_aware']) + ->willReturn($context_aware_plugin->reveal()); + $event = $this->prophesize(SectionComponentBuildRenderArrayEvent::class); + // We're not in a preview. + $event->inPreview()->willReturn(FALSE); + + // Build a component with our "context_aware" plugin in the visibility. + $component = new SectionComponent('', 'top'); + $component->set('visibility', [ + 'uuid' => ['id' => 'context_aware'], + ]); + $event->getComponent()->willReturn($component); + + // Setup a context array. + $context_definition = new ContextDefinition(); + $context_data = StringData::createInstance(DataDefinition::create('string')); + $context_data->setValue('foo'); + $context = [ + 'bar' => new Context($context_definition, $context_data), + ]; + + // Make sure the context is returned by the event. + $event->getContexts()->willReturn($context); + + // Steps that will be called in the process of resolving context. + $event->addCacheableDependency($context_aware_plugin) + ->shouldBeCalled(); + $context_aware_plugin->setContext('bar', $context['bar']) + ->shouldBeCalled(); + $context_aware_plugin->execute()->willReturn(TRUE); + $context_aware_plugin->getContextMapping()->willReturn([]); + $context_aware_plugin->getContext('bar')->willReturn(NULL); + // We want the "bar" context. + $context_aware_plugin->getContextDefinitions() + ->willReturn(['bar' => $context_definition]); + + // Run the onBuildRender handler so things run. + (new SectionComponentVisibility( + new ContextHandler(), + $condition_manager->reveal() + ))->onBuildRender($event->reveal()); + } + + /** + * Ensure visibility plugins control event propagation. + * + * @covers ::onBuildRender + * @dataProvider buildRenderResolves + */ + public function testOnBuildRenderNonPreviewResolve($result, $context_results) { + $uuid_factory = new UuidFactory(); + $visibility_def = []; + + $event = $this->prophesize(SectionComponentBuildRenderArrayEvent::class); + + // Set our mock condition manager to return the context plugin. + $condition_manager = $this->prophesize(ConditionManager::class); + foreach ($context_results as $plugin_id => $plugin_result) { + $plugin = $this->prophesize(ConditionInterface::class); + $condition_manager->createInstance($plugin_id, ['id' => $plugin_id]) + ->willReturn($plugin->reveal()); + $plugin->execute()->willReturn($plugin_result); + $visibility_def[$uuid_factory->generate()] = ['id' => $plugin_id]; + $event->addCacheableDependency($plugin)->shouldBeCalled(); + } + + // We're not in a preview. + $event->inPreview()->willReturn(FALSE); + + // Build a component with our "context_aware" plugin in the visibility. + $component = new SectionComponent('', 'top'); + $component->set('visibility', $visibility_def); + $event->getComponent()->willReturn($component); + + // Assert different propagation outcomes. + if ($result) { + $event->stopPropagation()->shouldNotBeCalled(); + } + else { + $event_plugin = $this->prophesize(PluginInspectionInterface::class)->reveal(); + $event->stopPropagation()->shouldBeCalled(); + $event->getPlugin()->willReturn($event_plugin); + } + + // Run the onBuildRender handler so things run. + (new SectionComponentVisibility( + new ContextHandler(), + $condition_manager->reveal() + ))->onBuildRender($event->reveal()); + + } + + /** + * Data Provider for testOnBuildRenderNonPreviewResolve(). + * + * @return array + * Method parameters for testOnBuildRenderNonPreviewResolve(). + */ + public function buildRenderResolves() { + return [ + [TRUE, ['foo' => TRUE, 'bar' => TRUE]], + [FALSE, ['foo' => TRUE, 'bar' => FALSE]], + [FALSE, ['foo' => TRUE, 'bar' => FALSE, 'biz' => TRUE]], + ]; + } + +} diff --git a/core/modules/views/tests/src/Functional/Plugin/DisplayFeedTest.php b/core/modules/views/tests/src/Functional/Plugin/DisplayFeedTest.php index dadae86f1d0..5c0cbe67982 100644 --- a/core/modules/views/tests/src/Functional/Plugin/DisplayFeedTest.php +++ b/core/modules/views/tests/src/Functional/Plugin/DisplayFeedTest.php @@ -80,9 +80,4 @@ public function testFeedOutput(): void { $this->assertEquals($node_link, $this->getSession()->getDriver()->getText('//item/link')); - // HTML should no longer be escaped since it is CDATA. Confirm it is - // wrapped in CDATA. - $this->assertSession()->responseContains('assertSession()->responseContains('

A paragraph

'); - // Confirm that the CDATA is closed properly. - $this->assertSession()->responseContains(']]>
'); + // Verify HTML is properly escaped in the description field. + $this->assertSession()->responseContains('<p>A paragraph</p>'); @@ -146,9 +141,4 @@ public function testFeedFieldOutput(): void { $this->assertEquals($node_link, $this->getSession()->getDriver()->getText('//item/link')); - // HTML should no longer be escaped since it is CDATA. Confirm it is wrapped - // in CDATA. - $this->assertSession()->responseContains('assertSession()->responseContains('

A paragraph

'); - // Confirm that the CDATA is closed properly. - $this->assertSession()->responseContains(']]>
'); + // Verify HTML is properly escaped in the description field. + $this->assertSession()->responseContains('<p>A paragraph</p>'); diff --git a/core/tests/Drupal/Tests/Core/EventSubscriber/RssResponseCdataTest.php b/core/tests/Drupal/Tests/Core/EventSubscriber/RssResponseCdataTest.php deleted file mode 100644 index 00ae45fbb0d..00000000000 --- a/core/tests/Drupal/Tests/Core/EventSubscriber/RssResponseCdataTest.php +++ /dev/null @@ -1,139 +0,0 @@ - - - - Drupal.org - https://www.drupal.org - Come for the software & stay for the community -Drupal is an open source content management platform powering millions of websites and applications. It’s built, used, and supported by an active and diverse community of people around the world. - en - - Drupal 8 turns one! - https://www.drupal.org/blog/drupal-8-turns-one - <a href="localhost/node/1">Hello&nbsp;</a> - - - - -RSS; - - $valid_expected_feed = << - - - Drupal.org - https://www.drupal.org - Come for the software & stay for the community -Drupal is an open source content management platform powering millions of websites and applications. It’s built, used, and supported by an active and diverse community of people around the world. - en - - Drupal 8 turns one! - https://www.drupal.org/blog/drupal-8-turns-one - Hello  - ]]> - - - - -RSS; - - $data['valid-feed'] = [$valid_feed, $valid_expected_feed]; - - $invalid_feed = << - - - Drupal.org - https://www.drupal.org - Come for the software, stay for the community -Drupal is an open source content management platform powering millions of websites and applications. It’s built, used, and supported by an active and diverse community of people around the world. - en - - Drupal 8 turns one! - https://www.drupal.org/blog/drupal-8-turns-one - - - - -//--> - -//--> - - ]]> - - - - -RSS; - - $data['invalid-feed'] = [$invalid_feed, $invalid_feed]; - return $data; - } - - /** - * @dataProvider providerTestOnResponse - * - * @param string $content - * The content for the request. - * @param string $expected_content - * The expected content from the response. - */ - public function testOnResponse(string $content, string $expected_content): void { - $event = new ResponseEvent( - $this->prophesize(HttpKernelInterface::class)->reveal(), - Request::create('/'), - HttpKernelInterface::MAIN_REQUEST, - new Response($content, 200, [ - 'Content-Type' => 'application/rss+xml', - ]) - ); - - $url_filter = new RssResponseCdata(); - $url_filter->onResponse($event); - - $this->assertEquals($expected_content, $event->getResponse()->getContent()); - } - -}