diff --git a/core/misc/cspell/dictionary.txt b/core/misc/cspell/dictionary.txt index 5c5fa46278..f6bbe7873d 100644 --- a/core/misc/cspell/dictionary.txt +++ b/core/misc/cspell/dictionary.txt @@ -364,6 +364,7 @@ druplicon drush drépal détruire +eacute editables editunblock eerste @@ -428,6 +429,7 @@ fielditem fieldlayout fieldlinks fieldnames +fieldnid fieldsets filelist filemime diff --git a/core/modules/layout_builder/css/layout-builder.css b/core/modules/layout_builder/css/layout-builder.css index 50bd2005b1..53821ee112 100644 --- a/core/modules/layout_builder/css/layout-builder.css +++ b/core/modules/layout_builder/css/layout-builder.css @@ -178,3 +178,7 @@ .layout-builder-components-table .tabledrag-changed-warning { display: none !important; } + +#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 4bbfcc9e64..60c3da0f28 100644 --- a/core/modules/layout_builder/layout_builder.links.contextual.yml +++ b/core/modules/layout_builder/layout_builder.links.contextual.yml @@ -27,3 +27,13 @@ layout_builder_block_remove: class: ['use-ajax'] data-dialog-type: dialog 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 1e5fd50ff1..6559ce2f4d 100644 --- a/core/modules/layout_builder/layout_builder.routing.yml +++ b/core/modules/layout_builder/layout_builder.routing.yml @@ -145,3 +145,42 @@ layout_builder.move_block: parameters: section_storage: 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 4e9fc3acda..cd248f3969 100644 --- a/core/modules/layout_builder/layout_builder.services.yml +++ b/core/modules/layout_builder/layout_builder.services.yml @@ -49,6 +49,11 @@ services: inline_block.usage: class: Drupal\layout_builder\InlineBlockUsage arguments: ['@database'] + 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: # Override the entity form controller to handle the entity layout_builder # operation. 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 0000000000..9d844b8b87 --- /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() { + // Run before BlockComponentRenderArray (priority 100), so that we can + // stop propagation and prevent rendering the component. + $events[LayoutBuilderEvents::SECTION_COMPONENT_BUILD_RENDER_ARRAY] = ['onBuildRender', 255]; + return $events; + } + + /** + * Determines the visibility of section component. + * + * @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 0000000000..a8e9af52a6 --- /dev/null +++ b/core/modules/layout_builder/src/Form/BlockVisibilityForm.php @@ -0,0 +1,357 @@ +conditionManager = $condition_manager; + $this->formBuilder = $form_builder; + $this->layoutTempstoreRepository = $layout_tempstore_repository; + $this->contextRepository = $context_repository; + } + + /** + * {@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('context.repository') + ); + } + + /** + * {@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->contextRepository->getAvailableContexts($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' => $this->t('@condition_name: @condition_summary', [ + '@condition_name' => $condition->getPluginDefinition()['label'], + '@condition_summary' => $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 (weight 10). + // 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 (weight 20) and the submit button (weight 40). + '#weight' => count($items) === 1 ? 30 : 0, + ]; + + // 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['#attributes']['data-layout-builder-target-highlight-id'] = $this->blockUpdateHighlightId($this->uuid); + + if ($this->isAjax()) { + $form['actions']['submit']['#ajax']['callback'] = '::ajaxSubmit'; + $form['update_operator']['#ajax']['callback'] = '::ajaxSubmit'; + } + + 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(ConfigureVisibilityForm::class, $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); + $form_state->setRedirectUrl($url); + } + + /** + * 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 0000000000..62bba9ea0f --- /dev/null +++ b/core/modules/layout_builder/src/Form/ConfigureVisibilityForm.php @@ -0,0 +1,330 @@ +layoutTempstoreRepository = $layout_tempstore_repository; + $this->conditionManager = $condition_manager; + $this->uuidGenerator = $uuid_generator; + $this->pluginFormFactory = $plugin_form_manager; + $this->classResolver = $class_resolver; + $this->contextRepository = $context_repository; + } + + /** + * {@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'), + $container->get('context.repository') + ); + } + + /** + * {@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->contextRepository->getAvailableContexts($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', + ]; + + // 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', + ]; + } + + 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 0000000000..c6c1ee1b1c --- /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 0000000000..06668c0b51 --- /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 0000000000..36992f342c --- /dev/null +++ b/core/modules/layout_builder/tests/src/FunctionalJavascript/BlockVisibilityTest.php @@ -0,0 +1,315 @@ +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']; + $assert_session->elementExists('css', "#layout-builder $rendered_block_selector .layout-builder-block-visibility a"); + } + + // 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', 'or'); + $page->checkField('settings[negate]'); + $page->findField('settings[pages]')->setValue('/node/2'); + $page->pressButton('Add condition'); + $assert_session->assertWaitOnAjaxRequest(); + $page->pressButton('Save layout'); + + // Test Current Theme visibility rule. + $this->removeVisibilityConditions(); + $this->beginAddCondition('current_theme'); + $page->checkField('settings[negate]'); + $page->findField('settings[theme]')->setValue('classy'); + $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. + * @param string $operator + * The and/or operator when multiple conditions present. + */ + protected function beginAddCondition($condition, $operator = '') { + $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->findField('condition')->setValue($condition); + $this->assertNotEmpty($assert_session->waitForElementVisible('css', '[value="Add condition"]')); + if (!empty($operator)) { + $page->findField('operator')->setValue($operator); + } + $page->pressButton('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 0000000000..a503f1c9a7 --- /dev/null +++ b/core/modules/layout_builder/tests/src/Unit/EventSubscriber/SectionComponentVisibilityTest.php @@ -0,0 +1,229 @@ +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]], + ]; + } + +}