diff --git a/core/modules/config_revert/config_revert.info.yml b/core/modules/config_revert/config_revert.info.yml new file mode 100644 index 0000000..939c868 --- /dev/null +++ b/core/modules/config_revert/config_revert.info.yml @@ -0,0 +1,7 @@ +name: Configuration Revert +type: module +description: 'Adds revert functionality to configuration management' +core: 8.x +configure: config_revert.report +dependencies: + - config diff --git a/core/modules/config_revert/config_revert.links.task.yml b/core/modules/config_revert/config_revert.links.task.yml new file mode 100644 index 0000000..d2d0cd0 --- /dev/null +++ b/core/modules/config_revert/config_revert.links.task.yml @@ -0,0 +1,5 @@ +config_revert.report: + route_name: config_revert.report + title: 'Updates report' + base_route: config.sync + weight: 1000 diff --git a/core/modules/config_revert/config_revert.module b/core/modules/config_revert/config_revert.module new file mode 100644 index 0000000..ab7a223 --- /dev/null +++ b/core/modules/config_revert/config_revert.module @@ -0,0 +1,24 @@ +' . t('About') . ''; + $output .= '

' . t('The Configuration Revert module provides a report that allows you to see the differences between the configuration items provided by the current versions of your installed modules, themes, and install profile, and the configuration on your site. From this report, you can also import new configuration provided by updates, and revert you site configuration to the provided values.') . '

'; + return $output; + + case 'config_revert.report': + return t('This report shows which configuration items provided by the current versions of your installed modules, themes, and install profile differ from the configuration items currently on your site.'); + } +} diff --git a/core/modules/config_revert/config_revert.permissions.yml b/core/modules/config_revert/config_revert.permissions.yml new file mode 100644 index 0000000..8b96fa9 --- /dev/null +++ b/core/modules/config_revert/config_revert.permissions.yml @@ -0,0 +1,5 @@ +view config updates report: + title: 'View config updates report' +revert configuration: + title: 'Revert configuration' + restrict access: true \ No newline at end of file diff --git a/core/modules/config_revert/config_revert.routing.yml b/core/modules/config_revert/config_revert.routing.yml new file mode 100644 index 0000000..226992c --- /dev/null +++ b/core/modules/config_revert/config_revert.routing.yml @@ -0,0 +1,36 @@ +config_revert.report: + path: '/admin/config/development/configuration/report/{config_type}' + defaults: + _form: '\Drupal\config_revert\Form\ConfigUpdatesReportForm' + _title: 'Updates report' + config_type: NULL + requirements: + _permission: 'view config updates report' + +config_revert.import: + path: '/admin/config/development/configuration/revert/import/{config_type}/{config_name}' + defaults: + _title: 'Import' + _controller: '\Drupal\config_revert\Controller\ConfigRevertOperationsController::import' + config_type: NULL + config_name: NULL + requirements: + _permission: 'import configuration' + +config_revert.diff: + path: '/admin/config/development/configuration/revert/diff/{config_type}/{config_name}' + defaults: + _title: 'Differences' + _controller: '\Drupal\config_revert\Controller\ConfigRevertOperationsController::diff' + config_type: NULL + config_name: NULL + requirements: + _permission: 'view config updates report' + +config_revert.revert: + path: '/admin/config/development/configuration/revert/revert/{config_type}/{config_name}' + defaults: + _title: 'Revert' + _form: '\Drupal\config_revert\Form\ConfigRevertConfirmForm' + requirements: + _permission: 'revert configuration' diff --git a/core/modules/config_revert/src/Controller/ConfigRevertOperationsController.php b/core/modules/config_revert/src/Controller/ConfigRevertOperationsController.php new file mode 100644 index 0000000..ced4b59 --- /dev/null +++ b/core/modules/config_revert/src/Controller/ConfigRevertOperationsController.php @@ -0,0 +1,213 @@ +entityManager = $entity_manager; + $this->activeConfigStorage = $active_config_storage; + $this->extensionConfigStorage = new ExtensionInstallStorage($active_config_storage); + $this->diffFormatter = $diff_formatter; + $this->diffFormatter->show_header = FALSE; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('entity.manager'), + $container->get('config.storage'), + $container->get('diff.formatter') + ); + } + + /** + * Imports configuration from a module, theme, or profile. + * + * Configuration is assumed not to currently exist. + * + * @param string $config_type + * The type of configuration. + * @param string $config_name + * The name of the config item, without the prefix. + * + * @return \Symfony\Component\HttpFoundation\RedirectResponse + * Redirects to the updates report. + */ + public function import($config_type, $config_name) { + // Read the config from the file. + $full_name = $this->getFullName($config_type, $config_name); + $value = $this->extensionConfigStorage->read($full_name); + + // Save it as a new config entity. + $entity_storage = $this->entityManager->getStorage($config_type); + $entity = $entity_storage->createFromStorageRecord($value); + $entity->save(); + + drupal_set_message($this->t('The configuration was imported.')); + $url = new Url('config_revert.report', array( + 'config_type' => $config_type, + )); + return new RedirectResponse($url->toString()); + } + + /** + * Shows the diff between active and provided configuration. + * + * @param string $config_type + * The type of configuration. + * @param string $config_name + * The name of the config item, without the prefix. + * + * @return array + * Render array for page showing differences between them. + */ + public function diff($config_type, $config_name) { + $full_name = $this->getFullName($config_type, $config_name); + $file = $this->normalizeConfig($this->extensionConfigStorage->read($full_name)); + $active = $this->normalizeConfig($this->activeConfigStorage->read($full_name)); + $diff = new Diff(explode("\n", Yaml::encode($active)), explode("\n", Yaml::encode($file))); + + $build = array(); + $build['#title'] = t('Config difference for @name', array('@name' => $full_name)); + $build['#attached']['library'][] = 'system/diff'; + + $build['diff'] = array( + '#type' => 'table', + '#header' => array( + array('data' => t('Site config'), 'colspan' => '2'), + array('data' => t('Source config'), 'colspan' => '2'), + ), + '#rows' => $this->diffFormatter->format($diff), + ); + + $url = new Url('config_revert.report', array( + 'config_type' => $config_type, + )); + + $build['back'] = array( + '#type' => 'link', + '#attributes' => array( + 'class' => array( + 'dialog-cancel', + ), + ), + '#title' => $this->t("Back to 'Updates report' page."), + '#url' => $url, + ); + + return $build; + } + + /** + * Returns the full name of a config item. + * + * @param string $type + * The config type. + * @param string $name + * The config name, without prefix. + * + * @return string + * The config item's full name. + */ + protected function getFullName($type, $name) { + $definition = $this->entityManager->getDefinition($type); + $prefix = $definition->getConfigPrefix(); + return $prefix . '.' . $name; + } + + /** + * Normalizes config for comparison. + * + * Recursively removes 'uuid' and 'dependencies' elements from configuration, + * as well as empty array values, and sorts at each level by array key, so + * that config from different storage can be compared meaningfully. + * + * @todo Not sure if dependencies should really be removed but they cause + * problems? I think there are some config/install files that are not + * OK and there is an issue to address this. + * + * @param array $config + * Configuration array to normalize. + * + * @return array + * Normalized configuration array. + */ + protected function normalizeConfig($config) { + unset($config['uuid']); + unset($config['dependencies']); + foreach ($config as $key => $value) { + if (is_array($value)) { + $new = $this->normalizeConfig($value); + if (count($new)) { + $config[$key] = $new; + } + else { + unset($config[$key]); + } + } + } + + ksort($config); + return $config; + } +} diff --git a/core/modules/config_revert/src/Form/ConfigRevertConfirmForm.php b/core/modules/config_revert/src/Form/ConfigRevertConfirmForm.php new file mode 100644 index 0000000..404ea0b --- /dev/null +++ b/core/modules/config_revert/src/Form/ConfigRevertConfirmForm.php @@ -0,0 +1,158 @@ +entityManager = $entity_manager; + $this->extensionConfigStorage = new ExtensionInstallStorage($active_config_storage); + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('entity.manager'), + $container->get('config.storage') + ); + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'config_revert_confirm'; + } + + /** + * {@inheritdoc} + */ + public function getQuestion() { + $config = $this->extensionConfigStorage->read($this->prefix . '.' . $this->name); + $definition = $this->entityManager->getDefinition($this->type); + $type_label = $definition->get('label'); + $item_label = (isset($config['label'])) ? $config['label'] : $this->name; + + return $this->t('Are you sure you want to revert the %type config %item to its source configuration?', array('%type' => $type_label, '%item' => $item_label )); + } + + /** + * {@inheritdoc} + */ + public function getCancelUrl() { + return new Url('config_revert.report', array( + 'config_type' => $this->type, + )); + } + + /** + * {@inheritdoc} + */ + public function getDescription() { + return $this->t('Customizations will be lost. This action cannot be undone.'); + } + + /** + * {@inheritdoc} + */ + public function getConfirmText() { + return $this->t('Revert'); + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state, $config_type = NULL, $config_name = NULL) { + $this->type = $config_type; + $this->name = $config_name; + $definition = $this->entityManager->getDefinition($this->type); + $this->prefix = $definition->getConfigPrefix(); + + $form = parent::buildForm($form, $form_state); + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $definition = $this->entityManager->getDefinition($this->type); + $id_key = $definition->getKey('id'); + + // Read the value from the file storage. + $full_name = $this->prefix . '.' . $this->name; + $value = $this->extensionConfigStorage->read($full_name); + + // Load the current config entity and replace the value. + $id = $value[$id_key]; + $entity_storage = $this->entityManager->getStorage($this->type); + $entity = $entity_storage->load($id); + $uuid = $entity->get('uuid'); + $entity = $entity_storage->updateFromStorageRecord($entity, $value); + $entity->set('uuid', $uuid); + $entity->save(); + + drupal_set_message($this->t('The configuration was reverted to its source.')); + $form_state->setRedirectUrl($this->getCancelUrl()); + } + +} diff --git a/core/modules/config_revert/src/Form/ConfigUpdatesReportForm.php b/core/modules/config_revert/src/Form/ConfigUpdatesReportForm.php new file mode 100644 index 0000000..94cdc8a --- /dev/null +++ b/core/modules/config_revert/src/Form/ConfigUpdatesReportForm.php @@ -0,0 +1,332 @@ +entityManager = $entity_manager; + $this->activeConfigStorage = $active_config_storage; + $this->extensionConfigStorage = new ExtensionInstallStorage($active_config_storage); + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('entity.manager'), + $container->get('config.storage') + ); + } + + /** + * {@inheritdoc} + */ + public function getFormID() { + return 'config_updates_report_form'; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state, $config_type = NULL) { + $this->typeFromUrl = $config_type; + $override_type = $form_state->getValue('config_type'); + if ($override_type) { + $config_type = $override_type; + } + + // Make a list of config entity types. We need the labels for the form, + // and the definitions in order to do things with them. + $entity_types = array(); + foreach ($this->entityManager->getDefinitions() as $entity_type => $definition) { + if ($definition->isSubclassOf('Drupal\Core\Config\Entity\ConfigEntityInterface')) { + $this->definitions[$entity_type] = $definition; + $entity_types[$entity_type] = $definition->getLabel(); + } + } + uasort($entity_types, 'strnatcasecmp'); + + if ($override_type) { + $config_type = $override_type; + } + + $form['config_type'] = array( + '#title' => $this->t('Configuration type'), + '#type' => 'select', + '#options' => $entity_types, + '#default_value' => $config_type, + '#required' => TRUE, + ); + + $form['submit'] = array( + '#type' => 'submit', + '#value' => $this->t('Generate report'), + ); + + if ($config_type) { + $form['report'] = $this->generateReport($config_type); + } + + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + // The URL may have a suffix on it, from a cancel or return link. If + // this doesn't match the submitted config type, the URL will be + // confusing, so redirect. + $override_type = $form_state->getValue('config_type'); + if ($this->typeFromUrl && $override_type && $this->typeFromUrl != $override_type) { + $form_state->disableRedirect(FALSE); + $form_state->setRedirect('config_revert.report', array( + 'config_type' => $override_type, + )); + return; + } + + // Report is generated from the buidForm() method, so just disable + // redirect and trigger a rebuild. + $form_state->disableRedirect(); + $form_state->setRebuild(); + } + + /** + * Generates a report about config updates. + * + * @param string $config_type + * Type of config to generate the report for, assumed to be the name + * of a config entity type that exists in $this->definitions. + * + * @return array + * Render array for the updates report. + */ + protected function generateReport($config_type) { + $type = $this->definitions[$config_type]; + $prefix = $type->getConfigPrefix(); + + $active_list = $this->activeConfigStorage->listAll($prefix); + $install_list = $this->extensionConfigStorage->listAll($prefix); + + $build = array(); + + $build['report_header'] = array('#markup' => '

' . $this->t('Updates report for %type configuration', array('%type' => $type->getLabel())) . '

'); + + $removed = array_diff($install_list, $active_list); + $build['removed'] = array( + '#caption' => $this->t('Missing configuration items'), + '#empty' => $this->t('None: all provided configuration items of this type are present on your site.'), + ) + $this->makeReportTable($removed, $config_type, $this->extensionConfigStorage, array('import')); + + $added = array_diff($active_list, $install_list); + $build['added'] = array( + '#caption' => $this->t('Added configuration items'), + '#empty' => $this->t('None: all configuration items of this type on your site came from your modules, themes, or install profile.'), + ) + $this->makeReportTable($added, $config_type, $this->activeConfigStorage, array('export')); + + $both = array_diff($active_list, $added); + $different = $this->listDifferent($both); + $build['different'] = array( + '#caption' => $this->t('Changed configuration items'), + '#empty' => $this->t('None: no configuration items of this type differ from their current provided versions.'), + ) + $this->makeReportTable($different, $config_type, $this->activeConfigStorage, array('diff', 'export', 'revert')); + + return $build; + } + + /** + * Builds a table for the report. + * + * @param string[] $names + * List of machine names of config items for the table. + * @param string $type + * Type of config. + * @param \Drupal\Core\Config\StorageInterface $storage + * Config storage the items can be loaded from. + * @param string[] $actions + * Action links to include, one or more of: + * - diff + * - revert + * - export + * - import + * + * @return array + * Render array for the table, not including the #empty and #prefix + * properties. + */ + protected function makeReportTable($names, $type, $storage, $actions) { + $definition = $this->definitions[$type]; + $id_key = $definition->getKey('id'); + + $build = array(); + + $build['#type'] = 'table'; + + $build['#header'] = array( + $this->t('Name'), + $this->t('Operations'), + ); + + $build['#rows'] = array(); + + foreach ($names as $name) { + $row = array(); + $config = $storage->read($name); + $id = $config[$id_key]; + $label = (isset($config['label'])) ? $config['label'] : $id; + $row[] = $label; + + $links = array(); + $routes = array( + 'export' => 'config.export_single', + 'import' => 'config_revert.import', + 'diff' => 'config_revert.diff', + 'revert' => 'config_revert.revert', + ); + $titles = array( + 'export' => $this->t('export from site'), + 'import' => $this->t('import from source'), + 'diff' => $this->t('show differences'), + 'revert' => $this->t('revert to source'), + ); + + foreach ($actions as $action) { + $links[$action] = array( + 'url' => Url::fromRoute($routes[$action], array('config_type' => $type, 'config_name' => $id)), + 'title' => $titles[$action], + ); + } + + $row[] = array('data' => array( + '#type' => 'operations', + '#links' => $links, + )); + + $build['#rows'][] = $row; + } + + return $build; + } + + /** + * Figures out which config items are different between extension and active. + * + * @param string[] $names + * List of machine names of configuration items that exist in both active + * and extension storage. + * + * @return string[] + * List of machine names from $names that are different between active and + * extension storage. + */ + protected function listDifferent($names) { + $different = array(); + foreach ($names as $name) { + $stored = $this->normalizeConfig($this->extensionConfigStorage->read($name)); + $active = $this->normalizeConfig($this->activeConfigStorage->read($name)); + if ($stored != $active) { + $different[] = $name; + } + } + + return $different; + } + + /** + * Normalizes config for comparison. + * + * Recursively removes 'uuid' and 'dependencies' elements from configuration, + * as well as empty array values, and sorts at each level by array key, so + * that config from different storage can be compared meaningfully. + * + * @todo Not sure if dependencies should really be removed but they cause + * problems? I think there are some config/install files that are not + * OK and there is an issue to address this. + * + * @param array $config + * Configuration array to normalize. + * + * @return array + * Normalized configuration array. + */ + protected function normalizeConfig($config) { + unset($config['uuid']); + unset($config['dependencies']); + foreach ($config as $key => $value) { + if (is_array($value)) { + $new = $this->normalizeConfig($value); + if (count($new)) { + $config[$key] = $new; + } + else { + unset($config[$key]); + } + } + } + + ksort($config); + return $config; + } +}