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..d2aa505 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'] + block_form.manager: + class: Drupal\Core\Block\BlockFormManager + 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/BlockFormManager.php b/core/lib/Drupal/Core/Block/BlockFormManager.php new file mode 100644 index 0000000..f866706 --- /dev/null +++ b/core/lib/Drupal/Core/Block/BlockFormManager.php @@ -0,0 +1,68 @@ +classResolver = $class_resolver; + } + + /** + * {@inheritdoc} + */ + public function getFormObject(PluginInspectionInterface $plugin, $operation) { + $definition = $plugin->getPluginDefinition(); + + if (!isset($definition['form'][$operation])) { + // Use the default form class if no form is specified for this operation. + if (isset($definition['form']['default'])) { + $operation = 'default'; + } + 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/Block/BlockFormManagerInterface.php b/core/lib/Drupal/Core/Block/BlockFormManagerInterface.php new file mode 100644 index 0000000..2135128 --- /dev/null +++ b/core/lib/Drupal/Core/Block/BlockFormManagerInterface.php @@ -0,0 +1,27 @@ +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 @@ +storage = $entity_manager->getStorage('block'); $this->manager = $manager; $this->contextRepository = $context_repository; $this->language = $language; $this->themeHandler = $theme_handler; + $this->blockFormManager = $block_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('block_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->blockFormManager->getFormObject($block, $this->operation); + } + } 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..3095963 --- /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..ff5ced3 --- /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->blockFormManager = $this->prophesize(BlockFormManagerInterface::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->blockFormManager->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..43dfce9 --- /dev/null +++ b/core/modules/outside_in/js/offcanvas.js @@ -0,0 +1,127 @@ +/** + * @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 $('', { + 'class': 'offcanvasClose', + 'aria-label': Drupal.t('Close configuration tray.'), + 'html': '' + Drupal.t('Close') + '' + }).click(function () { + pageWrapper + .removeClass('js-tray-open') + .one('webkitTransitionEnd otransitionend oTransitionEnd msTransitionEnd transitionend', function (e) { + Drupal.offCanvas.visible = false; + offCanvasWrapper.remove(); + Drupal.announce(Drupal.t('Configuration tray closed.')); + } + ); + }); + }; + + + /** + * Command to open an off canvas element. + * + * @param {Drupal.Ajax} ajax + * The Drupal Ajax object. + * @param {object} response + * Object holding the server response. + * @param {number} [status] + * The HTTP status code. + */ + Drupal.AjaxCommands.prototype.openOffCanvas = function (ajax, response, status) { + // Discover display/viewport size. + // @todo Work in breakpoints for tray size. + var $pageWrapper = $('#main-canvas-wrapper'); + var pageWidth = $pageWrapper.width(); + + // Set the initial state of the off canvas element. + // If the state has been set previously, use it. + Drupal.offCanvas = { + visible: (Drupal.offCanvas ? Drupal.offCanvas.visible : false) + }; + + // Construct off canvas wrapper + var $offcanvasWrapper = createOffCanvasWrapper(pageWidth); + + // Construct off canvas internal elements. + var $offcanvasClose = createOffCanvasClose($offcanvasWrapper, $pageWrapper); + var $title = createTitle(response.dialogOptions.title); + var $offcanvasContent = createOffCanvasContent(response.data); + + // Put everything together. + $offcanvasWrapper.append([$offcanvasClose, $title, $offcanvasContent]); + + // Handle opening or updating tray with content. + if (!Drupal.offCanvas.visible) { + // Append content then open tray. + $pageWrapper.append($offcanvasWrapper); + Drupal.offCanvas.visible = true; + $pageWrapper.addClass('js-tray-open'); + Drupal.announce(Drupal.t('Configuration tray opened.')); + } else { + // Remove previous content then append new content. + $pageWrapper.find('#offcanvas').remove(); + $pageWrapper.append($offcanvasWrapper); + Drupal.announce(Drupal.t('Configuration tray content has been updated.')); + } + }; + +})(jQuery, Drupal); diff --git a/core/modules/outside_in/js/outside_in.js b/core/modules/outside_in/js/outside_in.js new file mode 100644 index 0000000..8948780 --- /dev/null +++ b/core/modules/outside_in/js/outside_in.js @@ -0,0 +1,138 @@ +/** + * @file + * Drupal's Outside In library. + */ + +(function ($, Drupal) { + 'use strict'; + + // Bind a listener to the 'edit' button + // Toggle the js-outside-edit-mode class on items that we want + // to disable while in edit mode. + $('div.contextual-toolbar-tab.toolbar-tab button').click(function (e) { + setToggleActiveMode(); + }); + + // Bind an event listener to the .outside-in-editable div + // This listen for click events and stops default actions of those elements. + $('.outside-in-editable').on('click', '.js-outside-in-edit-mode', function (e) { + if (localStorage.getItem('Drupal.contextualToolbar.isViewing') === 'false') { + e.preventDefault(); + } + }); + + // Bind an event listener to the .outside-in-editable div + // When a click occurs try and find the outside-in edit link + // and click it. + $('.outside-in-editable') + .not('div.contextual a, div.contextual button') + .click(function (e) { + if ($(e.target.offsetParent).hasClass('contextual')) { + return; + } + if (!localStorage.getItem('Drupal.contextualToolbar.isViewing')) { + return; + } + var editLink = $(e.target).find('li.outside-inblock-configure a')[0]; + + (editLink ? editLink : $(e.target).parents('.outside-in-editable') + .find('li.outside-inblock-configure a')[0]) + .click(); + }); + + /** + * Add Ajax behaviours to links added by contextual links + * + * @todo Fix contextual links to work with use-ajax links. + * @see https://www.drupal.org/node/2764931 + * + * @param {jQuery.Event} event + * The `drupalContextualLinkAdded` event. + * @param {object} data + * An object containing the data relevant to the event. + * + * @listens event:drupalContextualLinkAdded + */ + $(document).on('drupalContextualLinkAdded', function (event, data) { + // Bind Ajax behaviors to all items showing the class. + data.$el.find('.use-ajax').once('ajax').each(function () { + // Below is copied directly from ajax.js to keep behavior the same. + var element_settings = {}; + // Clicked links look better with the throbber than the progress bar. + element_settings.progress = {type: 'throbber'}; + + // For anchor tags, these will go to the target of the anchor rather + // than the usual location. + var href = $(this).attr('href'); + if (href) { + element_settings.url = href; + element_settings.event = 'click'; + } + element_settings.dialogType = $(this).data('dialog-type'); + element_settings.dialog = $(this).data('dialog-options'); + element_settings.base = $(this).attr('id'); + element_settings.element = this; + Drupal.ajax(element_settings); + }); + + // Bind a listener to all 'Quick Edit' links for blocks + // Click "Edit" button in toolbar to force Contextual Edit which starts + // Outside In edit mode also. + data.$el.find('.outside-inblock-configure a').click(function (e) { + if (!isActiveMode()) { + $('div.contextual-toolbar-tab.toolbar-tab button').click(); + } + }); + }); + + /** + * Gets all items that should be toggled with class during edit mode. + * + * @returns {*} + * Items that should be toggled. + */ + var getItemsToToggle = function () { + return $('#main-canvas, #toolbar-bar, .outside-in-editable a, .outside-in-editable button') + .not('div.contextual a, div.contextual button'); + }; + + var isActiveMode = function () { + return $('#toolbar-bar').hasClass('js-outside-in-edit-mode'); + }; + + var setToggleActiveMode = function (forceActive) { + forceActive = forceActive || false; + if (forceActive || !isActiveMode()) { + $('#toolbar-bar .contextual-toolbar-tab button').text(Drupal.t('Editing')); + // Close the Manage tray if open when entering edit mode. + if ($('#toolbar-item-administration-tray').hasClass('is-active')) { + $('#toolbar-item-administration').click(); + } + getItemsToToggle().addClass('js-outside-in-edit-mode'); + $('.edit-mode-inactive').addClass('visually-hidden'); + } + else { + $('#toolbar-bar .contextual-toolbar-tab button').text(Drupal.t('Edit')); + getItemsToToggle().removeClass('js-outside-in-edit-mode'); + $('.edit-mode-inactive').removeClass('visually-hidden'); + } + }; + + /** + * Attaches contextual's edit toolbar tab behavior. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches contextual toolbar behavior on a contextualToolbar-init event. + */ + Drupal.behaviors.outsideinedit = { + attach: function (context) { + var editMode = localStorage.getItem('Drupal.contextualToolbar.isViewing') === 'false'; + if (editMode) { + setToggleActiveMode(true); + } + } + }; + +})(jQuery, Drupal); diff --git a/core/modules/outside_in/outside_in.info.yml b/core/modules/outside_in/outside_in.info.yml new file mode 100644 index 0000000..8fcb964 --- /dev/null +++ b/core/modules/outside_in/outside_in.info.yml @@ -0,0 +1,10 @@ +name: 'Outside In' +type: module +description: 'Provides the ability to access useful configuration from the Drupal front-end.' +package: Core (Experimental) +version: VERSION +core: 8.x +dependencies: + - block + - toolbar + - contextual diff --git a/core/modules/outside_in/outside_in.libraries.yml b/core/modules/outside_in/outside_in.libraries.yml new file mode 100644 index 0000000..7784954 --- /dev/null +++ b/core/modules/outside_in/outside_in.libraries.yml @@ -0,0 +1,22 @@ +drupal.outside_in: + version: VERSION + js: + js/outside_in.js: {} + css: + component: + css/outside_in.module.css: {} + css/outside_in.theme.css: {} + dependencies: + - core/jquery + - core/drupal +drupal.off_canvas: + version: VERSION + js: + js/offcanvas.js: {} + dependencies: + - core/jquery + - core/drupal + - core/drupal.ajax + - core/drupal.announce + - core/drupal.dialog + - core/drupal.dialog.ajax diff --git a/core/modules/outside_in/outside_in.links.contextual.yml b/core/modules/outside_in/outside_in.links.contextual.yml new file mode 100644 index 0000000..05455f3 --- /dev/null +++ b/core/modules/outside_in/outside_in.links.contextual.yml @@ -0,0 +1,4 @@ +outside_in.block_configure: + title: 'Quick Edit' + route_name: 'entity.block.offcanvas_form' + group: 'block' diff --git a/core/modules/outside_in/outside_in.module b/core/modules/outside_in/outside_in.module new file mode 100644 index 0000000..488f3a6 --- /dev/null +++ b/core/modules/outside_in/outside_in.module @@ -0,0 +1,121 @@ +' . t('About') . ''; + // @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' => '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..ad13628 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Block/MultipleBlockFormTest.php @@ -0,0 +1,37 @@ +createInstance('test_multiple_forms_block'); + + $form_object1 = \Drupal::service('block_form.manager')->getFormObject($block, 'default'); + $form_object2 = \Drupal::service('block_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/Block/BlockFormManagerTest.php b/core/tests/Drupal/Tests/Core/Block/BlockFormManagerTest.php new file mode 100644 index 0000000..7ea0e06 --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Block/BlockFormManagerTest.php @@ -0,0 +1,155 @@ +classResolver = $this->prophesize(ClassResolverInterface::class); + $this->manager = new BlockFormManager($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' => [ + 'default' => get_class($plugin->reveal()), + ], + ]); + + $form_object = $this->manager->getFormObject($plugin->reveal(), 'missing'); + $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); + } + +}