diff --git a/core/composer.json b/core/composer.json index 3109649..c7ca429 100644 --- a/core/composer.json +++ b/core/composer.json @@ -111,6 +111,7 @@ "drupal/migrate_drupal_ui": "self.version", "drupal/node": "self.version", "drupal/options": "self.version", + "drupal/outside_in": "self.version", "drupal/page_cache": "self.version", "drupal/path": "self.version", "drupal/quickedit": "self.version", diff --git a/core/core.services.yml b/core/core.services.yml index 2f7da56..39b0a04 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -569,6 +569,9 @@ services: entity.autocomplete_matcher: class: Drupal\Core\Entity\EntityAutocompleteMatcher arguments: ['@plugin.manager.entity_reference_selection'] + plugin_form.manager: + class: Drupal\Core\Plugin\PluginFormManager + arguments: ['@class_resolver'] plugin.manager.entity_reference_selection: class: Drupal\Core\Entity\EntityReferenceSelection\SelectionPluginManager parent: default_plugin_manager diff --git a/core/lib/Drupal/Core/Block/BlockManager.php b/core/lib/Drupal/Core/Block/BlockManager.php index 3c88bdc..036933f 100644 --- a/core/lib/Drupal/Core/Block/BlockManager.php +++ b/core/lib/Drupal/Core/Block/BlockManager.php @@ -8,6 +8,7 @@ use Drupal\Core\Plugin\CategorizingPluginManagerTrait; use Drupal\Core\Plugin\Context\ContextAwarePluginManagerTrait; use Drupal\Core\Plugin\DefaultPluginManager; +use Drupal\Core\Plugin\PluginFormInterface; /** * Manages discovery and instantiation of block plugins. @@ -48,6 +49,12 @@ public function __construct(\Traversable $namespaces, CacheBackendInterface $cac public function processDefinition(&$definition, $plugin_id) { parent::processDefinition($definition, $plugin_id); $this->processDefinitionCategory($definition); + + // If no default form is defined and this plugin implements + // \Drupal\Core\Plugin\PluginFormInterface, use that for the default form. + if (!isset($definition['form']['default']) && is_subclass_of($definition['class'], PluginFormInterface::class)) { + $definition['form']['default'] = $definition['class']; + } } /** diff --git a/core/lib/Drupal/Core/Form/OperationAwareFormInterface.php b/core/lib/Drupal/Core/Form/OperationAwareFormInterface.php new file mode 100644 index 0000000..8172bd7 --- /dev/null +++ b/core/lib/Drupal/Core/Form/OperationAwareFormInterface.php @@ -0,0 +1,20 @@ +defaults) && is_array($this->defaults)) { $definition = NestedArray::mergeDeep($this->defaults, $definition); } + + // If no default form is defined and this plugin implements + // \Drupal\Core\Plugin\PluginFormInterface, use that for the default form. + if (!isset($definition['form']['default']) && isset($definition['class']) && is_subclass_of($definition['class'], PluginFormInterface::class)) { + $definition['form']['default'] = $definition['class']; + } } /** diff --git a/core/lib/Drupal/Core/Plugin/PluginFormManager.php b/core/lib/Drupal/Core/Plugin/PluginFormManager.php new file mode 100644 index 0000000..dec70ba --- /dev/null +++ b/core/lib/Drupal/Core/Plugin/PluginFormManager.php @@ -0,0 +1,67 @@ +classResolver = $class_resolver; + } + + /** + * {@inheritdoc} + */ + public function getFormObject(PluginInspectionInterface $plugin, $operation, $fallback_operation = NULL) { + $definition = $plugin->getPluginDefinition(); + + if (!isset($definition['form'][$operation])) { + // Use the default form class if no form is specified for this operation. + if ($fallback_operation && isset($definition['form'][$fallback_operation])) { + $operation = $fallback_operation; + } + else { + throw new InvalidPluginDefinitionException($plugin->getPluginId(), sprintf('The "%s" plugin did not specify a "%s" form class', $plugin->getPluginId(), $operation)); + } + } + + // If the form specified is the plugin itself, use it directly. + if (get_class($plugin) === $definition['form'][$operation]) { + $form_object = $plugin; + } + else { + $form_object = $this->classResolver->getInstanceFromDefinition($definition['form'][$operation]); + } + + // Ensure the resulting object is a plugin form. + if (!$form_object instanceof PluginFormInterface) { + throw new InvalidPluginDefinitionException($plugin->getPluginId(), sprintf('The "%s" plugin did not specify a valid "%s" form class, must implement \Drupal\Core\Plugin\PluginFormInterface', $plugin->getPluginId(), $operation)); + } + + if ($form_object instanceof OperationAwareFormInterface) { + $form_object->setOperation($operation); + } + + return $form_object; + } + +} diff --git a/core/lib/Drupal/Core/Plugin/PluginFormManagerInterface.php b/core/lib/Drupal/Core/Plugin/PluginFormManagerInterface.php new file mode 100644 index 0000000..6038d61 --- /dev/null +++ b/core/lib/Drupal/Core/Plugin/PluginFormManagerInterface.php @@ -0,0 +1,29 @@ +storage = $entity_manager->getStorage('block'); $this->manager = $manager; $this->contextRepository = $context_repository; $this->language = $language; $this->themeHandler = $theme_handler; + $this->pluginFormManager = $plugin_form_manager; } /** @@ -99,7 +111,8 @@ public static function create(ContainerInterface $container) { $container->get('plugin.manager.condition'), $container->get('context.repository'), $container->get('language_manager'), - $container->get('theme_handler') + $container->get('theme_handler'), + $container->get('plugin_form.manager') ); } @@ -120,7 +133,7 @@ public function form(array $form, FormStateInterface $form_state) { $form_state->setTemporaryValue('gathered_contexts', $this->contextRepository->getAvailableContexts()); $form['#tree'] = TRUE; - $form['settings'] = $entity->getPlugin()->buildConfigurationForm(array(), $form_state); + $form['settings'] = $this->getPluginForm($this->entity->getPlugin())->buildConfigurationForm(array(), $form_state); $form['visibility'] = $this->buildVisibilityInterface([], $form_state); // If creating a new block, calculate a safe default machine name. @@ -282,7 +295,7 @@ public function validateForm(array &$form, FormStateInterface $form_state) { // settings form element, so just pass that to the block for validation. $settings = (new FormState())->setValues($form_state->getValue('settings')); // Call the plugin validate handler. - $this->entity->getPlugin()->validateConfigurationForm($form, $settings); + $this->getPluginForm($this->entity->getPlugin())->validateConfigurationForm($form, $settings); // Update the original form values. $form_state->setValue('settings', $settings->getValues()); $this->validateVisibility($form, $form_state); @@ -322,15 +335,14 @@ protected function validateVisibility(array $form, FormStateInterface $form_stat public function submitForm(array &$form, FormStateInterface $form_state) { parent::submitForm($form, $form_state); - $entity = $this->entity; // The Block Entity form puts all block plugin form elements in the // settings form element, so just pass that to the block for submission. // @todo Find a way to avoid this manipulation. $settings = (new FormState())->setValues($form_state->getValue('settings')); // Call the plugin submit handler. - $entity->getPlugin()->submitConfigurationForm($form, $settings); - $block = $entity->getPlugin(); + $block = $this->entity->getPlugin(); + $this->getPluginForm($block)->submitConfigurationForm($form, $settings); // If this block is context-aware, set the context mapping. if ($block instanceof ContextAwarePluginInterface && $block->getContextDefinitions()) { $context_mapping = $settings->getValue('context_mapping', []); @@ -339,7 +351,30 @@ public function submitForm(array &$form, FormStateInterface $form_state) { // Update the original form values. $form_state->setValue('settings', $settings->getValues()); - // Submit visibility condition settings. + $this->submitVisibility($form, $form_state); + + // Save the settings of the plugin. + $this->entity->save(); + + drupal_set_message($this->t('The block configuration has been saved.')); + $form_state->setRedirect( + 'block.admin_display_theme', + array( + 'theme' => $form_state->getValue('theme'), + ), + array('query' => array('block-placement' => Html::getClass($this->entity->id()))) + ); + } + + /** + * Helper function to independently submit the visibility UI. + * + * @param array $form + * A nested array form elements comprising the form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + */ + protected function submitVisibility(array $form, FormStateInterface $form_state) { foreach ($form_state->getValue('visibility') as $condition_id => $values) { // Allow the condition to submit the form. $condition = $form_state->get(['conditions', $condition_id]); @@ -354,20 +389,8 @@ public function submitForm(array &$form, FormStateInterface $form_state) { $condition_configuration = $condition->getConfiguration(); $form_state->setValue(['visibility', $condition_id], $condition_configuration); // Update the visibility conditions on the block. - $entity->getVisibilityConditions()->addInstanceId($condition_id, $condition_configuration); + $this->entity->getVisibilityConditions()->addInstanceId($condition_id, $condition_configuration); } - - // Save the settings of the plugin. - $entity->save(); - - drupal_set_message($this->t('The block configuration has been saved.')); - $form_state->setRedirect( - 'block.admin_display_theme', - array( - 'theme' => $form_state->getValue('theme'), - ), - array('query' => array('block-placement' => Html::getClass($this->entity->id()))) - ); } /** @@ -402,4 +425,17 @@ public function getUniqueMachineName(BlockInterface $block) { return $machine_default; } + /** + * Retrieves the plugin form for a given block and operation. + * + * @param \Drupal\Core\Block\BlockPluginInterface $block + * The block plugin. + * + * @return \Drupal\Core\Plugin\PluginFormInterface + * The plugin form for the block. + */ + protected function getPluginForm(BlockPluginInterface $block) { + return $this->pluginFormManager->getFormObject($block, 'default'); + } + } diff --git a/core/modules/block/tests/modules/block_test/src/Form/SecondaryBlockForm.php b/core/modules/block/tests/modules/block_test/src/Form/SecondaryBlockForm.php new file mode 100644 index 0000000..c5aac97 --- /dev/null +++ b/core/modules/block/tests/modules/block_test/src/Form/SecondaryBlockForm.php @@ -0,0 +1,47 @@ +operation = $operation; + } + + /** + * {@inheritdoc} + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state) { + return $form; + } + + /** + * {@inheritdoc} + */ + public function validateConfigurationForm(array &$form, FormStateInterface $form_state) { + // Intentionally empty. + } + + /** + * {@inheritdoc} + */ + public function submitConfigurationForm(array &$form, FormStateInterface $form_state) { + // Intentionally empty. + } + +} diff --git a/core/modules/block/tests/modules/block_test/src/Plugin/Block/TestMultipleFormsBlock.php b/core/modules/block/tests/modules/block_test/src/Plugin/Block/TestMultipleFormsBlock.php new file mode 100644 index 0000000..01a8fe2 --- /dev/null +++ b/core/modules/block/tests/modules/block_test/src/Plugin/Block/TestMultipleFormsBlock.php @@ -0,0 +1,27 @@ +method('getStorage') ->will($this->returnValue($this->storage)); + $this->pluginFormManager = $this->prophesize(PluginFormManagerInterface::class); } /** @@ -99,7 +108,7 @@ public function testGetUniqueMachineName() { ->method('getQuery') ->will($this->returnValue($query)); - $block_form_controller = new BlockForm($this->entityManager, $this->conditionManager, $this->contextRepository, $this->language, $this->themeHandler); + $block_form_controller = new BlockForm($this->entityManager, $this->conditionManager, $this->contextRepository, $this->language, $this->themeHandler, $this->pluginFormManager->reveal()); // Ensure that the block with just one other instance gets the next available // name suggestion. diff --git a/core/modules/outside_in/css/outside_in.module.css b/core/modules/outside_in/css/outside_in.module.css new file mode 100644 index 0000000..77745ba --- /dev/null +++ b/core/modules/outside_in/css/outside_in.module.css @@ -0,0 +1,128 @@ +/** + * @file + * Styling for Outside-In module. + */ + +/* Style the offcanvas container. */ +#offcanvas { + box-sizing: border-box; + position: fixed; + height: 100%; + overflow-y: auto; + display: inline-block; + width: 25%; + -webkit-transform: translateX(100%); + -moz-transform: translateX(100%); + -o-transform: translateX(100%); + -ms-transform: translateX(100%); + transform: translateX(100%); + /* 2s delay matches transition speed to transform speed. */ + -webkit-transition: all .7s ease-in-out 2s; + -moz-transition: all .7s ease-in-out 2s; + transition: all .7s ease-in-out 2s; + z-index: 500; +} +[dir="rtl"] #offcanvas { + text-align: right; + -webkit-transform: translateX(-100%); + -moz-transform: translateX(-100%); + -o-transform: translateX(-100%); + -ms-transform: translateX(-100%); + transform: translateX(-100%); +} +#main-canvas-wrapper.js-tray-open #offcanvas { + -webkit-transform: translateX(0); + -moz-transform: translateX(0); + -o-transform: translateX(0); + -ms-transform: translateX(0); + transform: translateX(0); +} +#main-canvas-wrapper #main-canvas { + display: inline-block; + width: 100%; + -webkit-transition: all .7s ease-in-out; + -moz-transition: all .7s ease-in-out; + transition: all .7s ease-in-out; +} +#main-canvas-wrapper.js-tray-open #main-canvas { + left: 0; /* LTR */ + width: 75%; + -webkit-transition: all .7s ease-in-out; + -moz-transition: all .7s ease-in-out; + transition: all .7s ease-in-out; +} +[dir="rtl"] #main-canvas-wrapper.js-tray-open #main-canvas { + right: 0; +} +/* Button that closes the offcanvas tray. */ +/* @todo Add hover state. */ +#offcanvas > button.offcanvasClose, +#offcanvas > button.offcanvasClose:hover { + position: absolute; + right: 15px; /* LTR */ + top: 15px; + height: 20px; + width: 20px; + border: 0; + border-radius: 0; + background: url(/core/misc/icons/bebebe/ex.svg) center center no-repeat; + color: transparent; + cursor: pointer; +} +#offcanvas > button.offcanvasClose:focus { + outline: none; +} +[dir="rtl"] #offcanvas > button.offcanvasClose { + left: 15px; + right: auto; +} +#offcanvas h1 { + padding: 15px; + margin-top: 0; + margin-bottom: 0; + font-size: 120%; +} +[dir="rtl"] #offcanvas h1 { + text-align: right; +} +#offcanvas > .offcanvas-content { + height: 10000px; + padding-top: 0; + padding-right: 15px; + padding-left: 15px; + padding-bottom: 0; +} +[dir="rtl"] #offcanvas .offcanvas-content{ + text-align: right; +} +#offcanvas .form-item { + width: 100%; +} +/** + * Change background when in edit mode an over editable item. + * Focus not taking effect because offcanvas load blurs element. + */ +/* @todo Add .is-active. */ +#main-canvas.js-outside-in-edit-mode .outside-in-editable:hover, +#main-canvas.js-outside-in-edit-mode .outside-in-editable:focus { + background-color: rgba(0, 0, 0, 0.1); +} + +/* Highlight editable regions in edit mode. */ +/* @todo Clean up this css. */ +#main-canvas.js-outside-in-edit-mode .outside-in-editable { + outline: -webkit-focus-ring-color auto 2px; + outline-color: -webkit-focus-ring-color; + outline-style: dashed; + outline-width: 1px; +} + +/* Edit toolbar tab. */ +/* @todo Move changes into toolbar module when outside-in is not experimental. */ +.toolbar .toolbar-bar .contextual-toolbar-tab.toolbar-tab { + float: left; +} +/* Always hide the toolbar tray in edit mode. */ +#toolbar-bar.js-outside-in-edit-mode .toolbar-tray .is-active { + display: none; +} diff --git a/core/modules/outside_in/css/outside_in.theme.css b/core/modules/outside_in/css/outside_in.theme.css new file mode 100644 index 0000000..1329fa9 --- /dev/null +++ b/core/modules/outside_in/css/outside_in.theme.css @@ -0,0 +1,81 @@ +/** + * @file + * Visual styling for Outside-In module. + */ + +/* Style the edit tab in the toolbar. */ +/* @todo Move this into core when module is not experimental. */ + +/* Smooth transitions between all toolbar states. */ +.toolbar-tab, +.toolbar-item, +.toolbar-icon:before { + -webkit-transition: all .7s ease-in-out; + -moz-transition: all .7s ease-in-out; + transition: all .7s ease-in-out; +} +/* Style for both edit and editing states. */ +button.toolbar-icon.toolbar-icon-edit.toolbar-item { + background: #0e69be; + background-image: -webkit-linear-gradient(top, #0094f0, #0e69be); + background-image: linear-gradient(to bottom, #0094f0, #0e69be); +} +button.toolbar-icon.toolbar-icon-edit.toolbar-item:hover, +button.toolbar-icon.toolbar-icon-edit.toolbar-item:focus { + background-image: -webkit-linear-gradient(top, #0094f0, #0e69be); + background-image: linear-gradient(to bottom, #0094f0, #0e69be); + color: #fff; +} +button.toolbar-icon.toolbar-icon-edit.toolbar-item:before:hover, +button.toolbar-icon.toolbar-icon-edit.toolbar-item:before:focus { + background-image: url(../../images/core/icons/ffffff/pencil.svg); +} +button.toolbar-icon.toolbar-icon-edit.toolbar-item:hover, +button.toolbar-icon.toolbar-icon-edit.toolbar-item:focus { + background-image: -webkit-linear-gradient(top, #0094f0, #0e69be); + background-image: linear-gradient(to bottom, #0094f0, #0e69be); + outline: none; +} +button.toolbar-icon.toolbar-icon-edit.toolbar-item:hover > .toolbar-icon-edit:before { + background-image: url(../../images/core/icons/ffffff/pencil.svg); +} +#toolbar-bar.button.toolbar-icon.toolbar-icon.toolbar-icon-edit:before { + background-image: url(../../images/core/icons/ffffff/pencil.svg); +} + +/* Toolbar when in edit mode. */ +#toolbar-bar.js-outside-in-edit-mode { + background-color: #fff; +} +/* Change text color for white background. */ +#toolbar-bar.js-outside-in-edit-mode .toolbar-item { + color: #999; +} +#toolbar-bar.js-outside-in-edit-mode .toolbar-item .is-active { + color: #333; +} +/* Set color back to white for 'editing' button only. */ +#toolbar-bar.js-outside-in-edit-mode button.toolbar-icon.toolbar-icon-edit.toolbar-item.is-active { + color: #fff; +} +#toolbar-bar.js-outside-in-edit-mode button.toolbar-icon.toolbar-icon-edit.toolbar-item.is-active:hover { + background-image: -webkit-linear-gradient(top, #0094f0, #0e69be); + background-image: linear-gradient(to bottom, #0094f0, #0e69be); +} + +/* Style the offcanvas container. */ +div#offcanvas { + background: #fff; + border-left: 1px solid #ddd; /* LTR */ + box-shadow: -2px 2px 1px 1px rgba(0, 0, 0, 0.1); /* LTR */ +} +[dir="rtl"] div#offcanvas { + border-right: 1px solid #ddd; + box-shadow: 2px 2px 1px 1px rgba(0, 0, 0, 0.1); +} + +/* Style the tray header. */ +#offcanvas h1 { + font-size: 120%; + border-bottom: 1px solid #ddd; +} diff --git a/core/modules/outside_in/js/offcanvas.js b/core/modules/outside_in/js/offcanvas.js new file mode 100644 index 0000000..e0fd9a7 --- /dev/null +++ b/core/modules/outside_in/js/offcanvas.js @@ -0,0 +1,132 @@ +/** + * @file + * Drupal's off canvas library. + */ + +(function ($, Drupal) { + 'use strict'; + + /** + * Create a wrapper container for the off canvas element. + * @param {number} pageWidth + * The width of #page-wrapper. + * @return {object} + * jQuery object that is the off canvas wrapper element. + */ + var createOffCanvasWrapper = function (pageWidth) { + return $('
', { + 'id': 'offcanvas', + 'role': 'region', + 'aria-labelledby': 'offcanvas-header', + 'css': { + 'background': '#fff', + 'background-color': '#fff', + 'border-left': '1px solid #ddd', + 'box-shadow': '-2px 3px 1px 1px rgba(0, 0, 0, 0.2)' + } + }); + }; + + /** + * Create the title element for the off canvas element. + * @param {string} title + * The title string. + * @return {object} + * jQuery object that is the off canvas title element. + */ + var createTitle = function (title) { + return $('

', {text: title, id: 'offcanvas-header'}); + }; + + /** + * Create the actual off canvas content. + * @param {string} data + * This is fully rendered html from Drupal. + * @return {object} + * jQuery object that is the off canvas content element. + */ + var createOffCanvasContent = function (data) { + return $('
', {class: 'offcanvas-content', html: data}); + }; + + /** + * Create the off canvas close element. + * @param {object} offCanvasWrapper + * The jQuery off canvas wrapper element + * @param {object} pageWrapper + * The jQuery off page wrapper element + * @return {object} + * jQuery object that is the off canvas close element. + */ + var createOffCanvasClose = function (offCanvasWrapper, pageWrapper) { + return $('

'; + // @todo Update help text. + $output .= '

' . t('The Outside In module is something that we should have help for. For more information, see the online documentation for the Outside In module.', [':outside-in-documentation' => 'https://www.drupal.org/documentation/modules/outside_in']) . '

'; + return $output; + } +} + +/** + * Implements hook_contextual_links_view_alter(). + * + * Change Configure Blocks into offcanvas links. + */ +function outside_in_contextual_links_view_alter(&$element, $items) { + if (isset($element['#links']['outside-inblock-configure'])) { + $element['#links']['outside-inblock-configure']['attributes'] = [ + 'class' => ['use-ajax'], + 'data-dialog-type' => 'offcanvas', + ]; + + $element['#attached'] = [ + 'library' => [ + 'outside_in/drupal.off_canvas', + ], + ]; + } +} + +/** + * Implements hook_page_top(). + * + * Opens a div for consistent wrapping to all {{ page }} render in all themes. + */ +function outside_in_page_top(array &$page_top) { + if (Drupal::service('outside_in.info')->useOutsideIn()) { + $page_top['outside_in_tray_open'] = [ + '#markup' => '
', + '#weight' => 1000 + ]; + } +} + +/** + * Implements hook_page_bottom(). + * + * Closes a div for consistent wrapping to all {{ page }} render in all themes. + */ +function outside_in_page_bottom(array &$page_bottom) { + if (Drupal::service('outside_in.info')->useOutsideIn()) { + $page_bottom['outside_in_tray_close'] = [ + '#markup' => '
', + '#weight' => -1000 + ]; + } +} + +/** + * Implements hook_entity_type_build(). + */ +function outside_in_entity_type_build(array &$entity_types) { + /** @var $entity_types \Drupal\Core\Entity\EntityTypeInterface[] */ + $entity_types['block'] + ->setFormClass('offcanvas', BlockEntityOffCanvasForm::class) + ->setLinkTemplate('offcanvas-form', '/admin/structure/block/manage/{block}/offcanvas'); +} + +/** + * Implements hook_preprocess_HOOK() for block templates. + * + * Adds 'outside-in-editable' class to all blocks to allow Javascript to target. + */ +function outside_in_preprocess_block(&$variables) { + // Remove on Admin routes. + $admin_route = \Drupal::service('router.admin_context')->isAdminRoute(); + // @todo Check if there is actually different admin theme. + // Remove on Block Demo page. + $admin_demo = \Drupal::routeMatch()->getRouteName() === 'block.admin_demo'; + $access = (\Drupal::currentUser()->hasPermission('administer blocks') && !$admin_route && !$admin_demo); + + if (!$access) { + return; + } + $variables['attributes']['class'][] = 'outside-in-editable'; +} + +/** + * Implements hook_toolbar_alter(). + * + * Includes outside_library if Edit link is in toolbar. + */ +function outside_in_toolbar_alter(&$items) { + if (Drupal::service('outside_in.info')->useOutsideIn() && isset($items['contextual']['tab'])) { + $items['contextual']['#weight'] = -1000; + $items['contextual']['#attached']['library'][] = 'outside_in/drupal.outside_in'; + + // Set a class on items to mark whether they should be active in edit mode. + // @todo Create a dynamic method for modules to set their own items. + $edit_mode_items = ['contextual', 'block_place']; + foreach ($items as $key => $item) { + if (!in_array($key, $edit_mode_items) && + (!is_array($items[$key]['#wrapper_attributes']['class']) + || !in_array('hidden',$items[$key]['#wrapper_attributes']['class']))) { + $items[$key]['#wrapper_attributes']['class'][] = 'edit-mode-inactive'; + } + } + } +} diff --git a/core/modules/outside_in/outside_in.routing.yml b/core/modules/outside_in/outside_in.routing.yml new file mode 100644 index 0000000..5c81541 --- /dev/null +++ b/core/modules/outside_in/outside_in.routing.yml @@ -0,0 +1,7 @@ +entity.block.offcanvas_form: + path: '/admin/structure/block/manage/{block}/offcanvas' + defaults: + _entity_form: 'block.offcanvas' + _title: 'Configure block' + requirements: + _permission: 'administer blocks' diff --git a/core/modules/outside_in/outside_in.services.yml b/core/modules/outside_in/outside_in.services.yml new file mode 100644 index 0000000..c240a18 --- /dev/null +++ b/core/modules/outside_in/outside_in.services.yml @@ -0,0 +1,10 @@ +services: + main_content_renderer.off_canvas: + class: Drupal\outside_in\Render\MainContent\OffCanvasRender + arguments: ['@title_resolver', '@renderer'] + tags: + - { name: render.main_content_renderer, format: drupal_offcanvas } + + outside_in.info: + class: Drupal\outside_in\PageInfo + arguments: ['@current_route_match', '@theme.negotiator.admin_theme'] diff --git a/core/modules/outside_in/src/Ajax/OpenOffCanvasDialogCommand.php b/core/modules/outside_in/src/Ajax/OpenOffCanvasDialogCommand.php new file mode 100644 index 0000000..ef6f272 --- /dev/null +++ b/core/modules/outside_in/src/Ajax/OpenOffCanvasDialogCommand.php @@ -0,0 +1,54 @@ +dialogOptions['modal'] = FALSE; + return [ + 'command' => 'openOffCanvas', + 'selector' => $this->selector, + 'settings' => $this->settings, + 'data' => $this->getRenderedContent(), + 'dialogOptions' => $this->dialogOptions, + ]; + } + +} diff --git a/core/modules/outside_in/src/Block/BlockEntityOffCanvasForm.php b/core/modules/outside_in/src/Block/BlockEntityOffCanvasForm.php new file mode 100644 index 0000000..a77a6a6 --- /dev/null +++ b/core/modules/outside_in/src/Block/BlockEntityOffCanvasForm.php @@ -0,0 +1,54 @@ +pluginFormManager->getFormObject($block, 'offcanvas', 'default'); + } + +} diff --git a/core/modules/outside_in/src/PageInfo.php b/core/modules/outside_in/src/PageInfo.php new file mode 100644 index 0000000..649439a --- /dev/null +++ b/core/modules/outside_in/src/PageInfo.php @@ -0,0 +1,50 @@ +adminThemeNegotiator = $admin_theme_negotiator; + $this->routeMatch = $route_match; + } + + /** + * Determines whether outside should be use in the current requests. + * + * @return bool + * True if Outside In should be applied to current request. + */ + public function useOutsideIn() { + return !$this->adminThemeNegotiator->applies($this->routeMatch); + } + +} diff --git a/core/modules/outside_in/src/Render/MainContent/OffCanvasRender.php b/core/modules/outside_in/src/Render/MainContent/OffCanvasRender.php new file mode 100644 index 0000000..5202f40 --- /dev/null +++ b/core/modules/outside_in/src/Render/MainContent/OffCanvasRender.php @@ -0,0 +1,63 @@ +renderer = $renderer; + } + + /** + * {@inheritdoc} + */ + public function renderResponse(array $main_content, Request $request, RouteMatchInterface $route_match) { + $response = new AjaxResponse(); + + // First render the main content, because it might provide a title. + $content = $this->renderer->renderRoot($main_content); + + // Attach the library necessary for using the OpenModalDialogCommand and set + // the attachments for this Ajax response. + $main_content['#attached']['library'][] = 'outside_in/drupal.off_canvas'; + $response->setAttachments($main_content['#attached']); + + // If the main content doesn't provide a title, use the title resolver. + $title = isset($main_content['#title']) ? $main_content['#title'] : $this->titleResolver->getTitle($request, $route_match->getRouteObject()); + + // Determine the title: use the title provided by the main content if any, + // otherwise get it from the routing information. + $options = $request->request->get('dialogOptions', []); + + $response->addCommand(new OpenOffCanvasDialogCommand($title, $content, $options)); + return $response; + } + +} diff --git a/core/modules/outside_in/src/Tests/Ajax/OffCanvasDialogTest.php b/core/modules/outside_in/src/Tests/Ajax/OffCanvasDialogTest.php new file mode 100644 index 0000000..2df1e71 --- /dev/null +++ b/core/modules/outside_in/src/Tests/Ajax/OffCanvasDialogTest.php @@ -0,0 +1,51 @@ +drupalLogin($this->drupalCreateUser(['administer contact forms'])); + // Ensure the elements render without notices or exceptions. + $this->drupalGet('ajax-test/dialog'); + + // Set up variables for this test. + $dialog_renderable = AjaxTestController::dialogContents(); + $dialog_contents = \Drupal::service('renderer')->renderRoot($dialog_renderable); + + $offcanvas_expected_response = [ + 'command' => 'openOffCanvas', + 'selector' => '#drupal-offcanvas', + 'settings' => NULL, + 'data' => $dialog_contents, + 'dialogOptions' => [ + 'modal' => FALSE, + 'title' => 'AJAX Dialog contents', + ], + ]; + + // Emulate going to the JS version of the page and check the JSON response. + $ajax_result = $this->drupalGetAjax('ajax-test/dialog-contents', ['query' => [MainContentViewSubscriber::WRAPPER_FORMAT => 'drupal_offcanvas']]); + $this->assertEqual($offcanvas_expected_response, $ajax_result[3], 'Off-canvas dialog JSON response matches.'); + } + +} diff --git a/core/modules/outside_in/tests/modules/offcanvas_test/offcanvas_test.info.yml b/core/modules/outside_in/tests/modules/offcanvas_test/offcanvas_test.info.yml new file mode 100644 index 0000000..8c6cc80 --- /dev/null +++ b/core/modules/outside_in/tests/modules/offcanvas_test/offcanvas_test.info.yml @@ -0,0 +1,9 @@ +name: 'Off-canvas tests' +type: module +description: 'Provides off-canvas test links.' +package: Testing +version: VERSION +core: 8.x +dependencies: + - block + - outside_in diff --git a/core/modules/outside_in/tests/modules/offcanvas_test/offcanvas_test.routing.yml b/core/modules/outside_in/tests/modules/offcanvas_test/offcanvas_test.routing.yml new file mode 100644 index 0000000..7bfd52b --- /dev/null +++ b/core/modules/outside_in/tests/modules/offcanvas_test/offcanvas_test.routing.yml @@ -0,0 +1,23 @@ +offcanvas_test.links: + path: '/offcanvas-test-links' + defaults: + _controller: '\Drupal\offcanvas_test\Controller\TestController::linksDisplay' + _title: 'Links' + requirements: + _access: 'TRUE' + +offcanvas_test.thing1: + path: '/offcanvas-thing1' + defaults: + _controller: '\Drupal\offcanvas_test\Controller\TestController::thing1' + _title: 'Thing 1' + requirements: + _access: 'TRUE' + +offcanvas_test.thing2: + path: '/offcanvas-thing2' + defaults: + _controller: '\Drupal\offcanvas_test\Controller\TestController::thing2' + _title: 'Thing 2' + requirements: + _access: 'TRUE' diff --git a/core/modules/outside_in/tests/modules/offcanvas_test/src/Controller/TestController.php b/core/modules/outside_in/tests/modules/offcanvas_test/src/Controller/TestController.php new file mode 100644 index 0000000..8bf28ad --- /dev/null +++ b/core/modules/outside_in/tests/modules/offcanvas_test/src/Controller/TestController.php @@ -0,0 +1,77 @@ + 'markup', + '#markup' => 'Thing 1 says hello', + ]; + } + + /** + * Thing2. + * + * @return string + * Return Hello string. + */ + public function thing2() { + return [ + '#type' => 'markup', + '#markup' => 'Thing 2 says hello', + ]; + } + + /** + * Display test links that will open in offcanvas tray. + * + * @return array + * Render array with links. + */ + public function linksDisplay() { + return [ + 'offcanvas_link_1' => [ + '#title' => 'Click Me 1!', + '#type' => 'link', + '#url' => Url::fromRoute('offcanvas_test.thing1'), + '#attributes' => [ + 'class' => ['use-ajax'], + 'data-dialog-type' => 'offcanvas', + ], + '#attached' => [ + 'library' => [ + 'outside_in/drupal.off_canvas', + ], + ], + ], + 'offcanvas_link_2' => [ + '#title' => 'Click Me 2!', + '#type' => 'link', + '#url' => Url::fromRoute('offcanvas_test.thing2'), + '#attributes' => [ + 'class' => ['use-ajax'], + 'data-dialog-type' => 'offcanvas', + ], + '#attached' => [ + 'library' => [ + 'outside_in/drupal.off_canvas', + ], + ], + ], + + ]; + } + +} diff --git a/core/modules/outside_in/tests/modules/offcanvas_test/src/Plugin/Block/TestBlock.php b/core/modules/outside_in/tests/modules/offcanvas_test/src/Plugin/Block/TestBlock.php new file mode 100644 index 0000000..da72f9b --- /dev/null +++ b/core/modules/outside_in/tests/modules/offcanvas_test/src/Plugin/Block/TestBlock.php @@ -0,0 +1,49 @@ + [ + '#title' => $this->t('Click Me 1!'), + '#type' => 'link', + '#url' => Url::fromRoute('offcanvas_test.thing1'), + '#attributes' => [ + 'class' => ['use-ajax'], + 'data-dialog-type' => 'offcanvas', + ], + ], + 'offcanvas_link_2' => [ + '#title' => $this->t('Click Me 2!'), + '#type' => 'link', + '#url' => Url::fromRoute('offcanvas_test.thing2'), + '#attributes' => [ + 'class' => ['use-ajax'], + 'data-dialog-type' => 'offcanvas', + ], + ], + '#attached' => [ + 'library' => [ + 'outside_in/drupal.off_canvas', + ], + ], + ]; + } + +} diff --git a/core/modules/outside_in/tests/src/FunctionalJavascript/OffCanvasTest.php b/core/modules/outside_in/tests/src/FunctionalJavascript/OffCanvasTest.php new file mode 100644 index 0000000..9e9f6ba --- /dev/null +++ b/core/modules/outside_in/tests/src/FunctionalJavascript/OffCanvasTest.php @@ -0,0 +1,88 @@ +install([$theme]); + $theme_config = \Drupal::configFactory()->getEditable('system.theme'); + $theme_config->set('default', $theme); + $theme_config->save(); + $this->drupalGet('/offcanvas-test-links'); + + $page = $this->getSession()->getPage(); + $web_assert = $this->assertSession(); + + // Make sure off-canvas tray is on page when first loaded. + $web_assert->elementNotExists('css', '#offcanvas'); + + // Check opening and closing with two separate links. + // Make sure tray updates to new content. + foreach (['1', '2'] as $link_index) { + // Click the first test like that should open the page. + $page->clickLink("Click Me $link_index!"); + $this->waitForOffCanvasToOpen(); + + // Check that the canvas is not on the page. + $web_assert->elementExists('css', '#offcanvas'); + // Check that response text is on page. + $web_assert->pageTextContains("Thing $link_index says hello"); + $offcanvas_tray = $page->findById('offcanvas'); + + // Check that tray is visible. + $this->assertEquals(TRUE, $offcanvas_tray->isVisible()); + $header_text = $offcanvas_tray->findById('offcanvas-header')->getText(); + + // Check that header is correct. + $this->assertEquals("Thing $link_index", $header_text); + $tray_text = $offcanvas_tray->find('css', '.offcanvas-content')->getText(); + $this->assertEquals("Thing $link_index says hello", $tray_text); + } + } + } + + /** + * Waits for Off-canvas tray to close. + */ + protected function waitForOffCanvasToClose() { + $condition = "(jQuery('#offcanvas').length == 0)"; + $this->assertJsCondition($condition, 5000); + } + + /** + * Waits for Off-canvas tray to open. + */ + protected function waitForOffCanvasToOpen() { + $condition = "(jQuery('#offcanvas').length > 0)"; + $this->assertJsCondition($condition, 5000); + } + +} diff --git a/core/modules/outside_in/tests/src/Unit/Ajax/OpenOffCanvasDialogCommandTest.php b/core/modules/outside_in/tests/src/Unit/Ajax/OpenOffCanvasDialogCommandTest.php new file mode 100644 index 0000000..45ff2a1 --- /dev/null +++ b/core/modules/outside_in/tests/src/Unit/Ajax/OpenOffCanvasDialogCommandTest.php @@ -0,0 +1,34 @@ +Text!

', ['url' => 'example']); + + $expected = [ + 'command' => 'openOffCanvas', + 'selector' => '#drupal-offcanvas', + 'settings' => NULL, + 'data' => '

Text!

', + 'dialogOptions' => [ + 'url' => 'example', + 'title' => 'Title', + 'modal' => FALSE, + ], + ]; + $this->assertEquals($expected, $command->render()); + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Block/MultipleBlockFormTest.php b/core/tests/Drupal/KernelTests/Core/Block/MultipleBlockFormTest.php new file mode 100644 index 0000000..117db9d --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Block/MultipleBlockFormTest.php @@ -0,0 +1,37 @@ +createInstance('test_multiple_forms_block'); + + $form_object1 = \Drupal::service('plugin_form.manager')->getFormObject($block, 'default'); + $form_object2 = \Drupal::service('plugin_form.manager')->getFormObject($block, 'secondary'); + + // Assert that the block itself is used for the default form. + $this->assertSame($block, $form_object1); + + $expected_secondary = new SecondaryBlockForm(); + $expected_secondary->setOperation('secondary'); + $this->assertEquals($expected_secondary, $form_object2); + } + +} diff --git a/core/tests/Drupal/Tests/Core/Plugin/PluginFormManagerTest.php b/core/tests/Drupal/Tests/Core/Plugin/PluginFormManagerTest.php new file mode 100644 index 0000000..193899c --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Plugin/PluginFormManagerTest.php @@ -0,0 +1,155 @@ +classResolver = $this->prophesize(ClassResolverInterface::class); + $this->manager = new PluginFormManager($this->classResolver->reveal()); + } + + /** + * @covers ::getFormObject + */ + public function testGetFormObject() { + $plugin_form = $this->prophesize(PluginFormInterface::class); + $expected = $plugin_form->reveal(); + + $this->classResolver->getInstanceFromDefinition(get_class($expected))->willReturn($expected); + + $plugin = $this->prophesize(PluginInspectionInterface::class); + $plugin->getPluginDefinition()->willReturn([ + 'form' => [ + 'standard_class' => get_class($expected), + ], + ]); + + $form_object = $this->manager->getFormObject($plugin->reveal(), 'standard_class'); + $this->assertSame($expected, $form_object); + } + + /** + * @covers ::getFormObject + */ + public function testGetFormObjectUsingPlugin() { + $this->classResolver->getInstanceFromDefinition(Argument::cetera())->shouldNotBeCalled(); + + $plugin = $this->prophesize(PluginInspectionInterface::class)->willImplement(PluginFormInterface::class); + $plugin->getPluginDefinition()->willReturn([ + 'form' => [ + 'default' => get_class($plugin->reveal()), + ], + ]); + + $form_object = $this->manager->getFormObject($plugin->reveal(), 'default'); + $this->assertSame($plugin->reveal(), $form_object); + } + + /** + * @covers ::getFormObject + */ + public function testGetFormObjectDefaultFallback() { + $this->classResolver->getInstanceFromDefinition(Argument::cetera())->shouldNotBeCalled(); + + $plugin = $this->prophesize(PluginInspectionInterface::class)->willImplement(PluginFormInterface::class); + $plugin->getPluginDefinition()->willReturn([ + 'form' => [ + 'fallback' => get_class($plugin->reveal()), + ], + ]); + + $form_object = $this->manager->getFormObject($plugin->reveal(), 'missing', 'fallback'); + $this->assertSame($plugin->reveal(), $form_object); + } + + /** + * @covers ::getFormObject + */ + public function testGetFormObjectOperationAware() { + $plugin_form = $this->prophesize(PluginFormInterface::class)->willImplement(OperationAwareFormInterface::class); + $plugin_form->setOperation('operation_aware')->shouldBeCalled(); + + $expected = $plugin_form->reveal(); + + $this->classResolver->getInstanceFromDefinition(get_class($expected))->willReturn($expected); + + $plugin = $this->prophesize(PluginInspectionInterface::class); + $plugin->getPluginDefinition()->willReturn([ + 'form' => [ + 'operation_aware' => get_class($expected), + ], + ]); + + $form_object = $this->manager->getFormObject($plugin->reveal(), 'operation_aware'); + $this->assertSame($expected, $form_object); + } + + /** + * @covers ::getFormObject + */ + public function testGetFormObjectDefinitionException() { + $this->setExpectedException(InvalidPluginDefinitionException::class, 'The "the_plugin_id" plugin did not specify a "anything" form class'); + + $plugin = $this->prophesize(PluginInspectionInterface::class); + $plugin->getPluginId()->willReturn('the_plugin_id'); + $plugin->getPluginDefinition()->willReturn([]); + + $form_object = $this->manager->getFormObject($plugin->reveal(), 'anything'); + $this->assertSame(NULL, $form_object); + } + + /** + * @covers ::getFormObject + */ + public function testGetFormObjectInvalidException() { + $this->setExpectedException(InvalidPluginDefinitionException::class, 'The "the_plugin_id" plugin did not specify a valid "invalid" form class, must implement \Drupal\Core\Plugin\PluginFormInterface'); + + $expected = new \stdClass(); + $this->classResolver->getInstanceFromDefinition(get_class($expected))->willReturn($expected); + + $plugin = $this->prophesize(PluginInspectionInterface::class); + $plugin->getPluginId()->willReturn('the_plugin_id'); + $plugin->getPluginDefinition()->willReturn([ + 'form' => [ + 'invalid' => get_class($expected), + ], + ]); + + $form_object = $this->manager->getFormObject($plugin->reveal(), 'invalid'); + $this->assertSame(NULL, $form_object); + } + +}