',
+ '#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 </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());
- }
-
-}