diff --git a/core/includes/config.inc b/core/includes/config.inc index 3591263..ffd7036 100644 --- a/core/includes/config.inc +++ b/core/includes/config.inc @@ -4,6 +4,7 @@ use Drupal\Core\Config\ConfigException; use Drupal\Core\Config\FileStorage; use Drupal\Core\Config\StorageInterface; +use Symfony\Component\Yaml\Dumper; /** * @file @@ -422,3 +423,43 @@ function config_get_entity_type_by_name($name) { function config_typed() { return drupal_container()->get('config.typed'); } + +/** + * Return a formatted diff of a named config between two storages. + * + * @param Drupal\Core\Config\StorageInterface $source_storage + * The storage to diff configuration from. + * @param Drupal\Core\Config\StorageInterface $target_storage + * The storage to diff configuration to. + * @param string $name + * The name of the configuration object to diff. + * + * @return core/lib/Drupal/Component/Diff + * A formatted string showing the difference between the two storages. + * + * @todo Make renderer injectable + */ +function config_diff(StorageInterface $source_storage, StorageInterface $target_storage, $name) { + require_once DRUPAL_ROOT . '/core/lib/Drupal/Component/Diff/DiffEngine.php'; + + // The output should show configuration object differences formatted as YAML. + // But the configuration is not necessarily stored in files. Therefore, they + // need to be read and parsed, and lastly, dumped into YAML strings. + $dumper = new Dumper(); + $dumper->setIndentation(2); + + $source_data = explode("\n", $dumper->dump($source_storage->read($name), PHP_INT_MAX)); + $target_data = explode("\n", $dumper->dump($target_storage->read($name), PHP_INT_MAX)); + + // Check for new or removed files. + if ($source_data === array('false')) { + // Added file. + $source_data = array(t('File added')); + } + if ($target_data === array('false')) { + // Deleted file. + $target_data = array(t('File removed')); + } + + return new Diff($source_data, $target_data); +} diff --git a/core/lib/Drupal/Component/Diff/DiffEngine.php b/core/lib/Drupal/Component/Diff/DiffEngine.php index 1236610..f426b96 100644 --- a/core/lib/Drupal/Component/Diff/DiffEngine.php +++ b/core/lib/Drupal/Component/Diff/DiffEngine.php @@ -1111,11 +1111,11 @@ function _end_diff() { function _block_header($xbeg, $xlen, $ybeg, $ylen) { return array( array( - 'data' => theme('diff_header_line', array('lineno' => $xbeg + $this->line_stats['offset']['x'])), + 'data' => $xbeg + $this->line_stats['offset']['x'], 'colspan' => 2, ), array( - 'data' => theme('diff_header_line', array('lineno' => $ybeg + $this->line_stats['offset']['y'])), + 'data' => $ybeg + $this->line_stats['offset']['y'], 'colspan' => 2, ) ); @@ -1143,7 +1143,7 @@ function addedLine($line) { 'class' => 'diff-marker', ), array( - 'data' => theme('diff_content_line', array('line' => $line)), + 'data' => $line, 'class' => 'diff-context diff-addedline', ) ); @@ -1159,7 +1159,7 @@ function deletedLine($line) { 'class' => 'diff-marker', ), array( - 'data' => theme('diff_content_line', array('line' => $line)), + 'data' => $line, 'class' => 'diff-context diff-deletedline', ) ); @@ -1172,7 +1172,7 @@ function contextLine($line) { return array( ' ', array( - 'data' => theme('diff_content_line', array('line' => $line)), + 'data' => $line, 'class' => 'diff-context', ) ); @@ -1181,7 +1181,7 @@ function contextLine($line) { function emptyLine() { return array( ' ', - theme('diff_empty_line', array('line' => ' ')), + ' ', ); } diff --git a/core/modules/config/config.admin.css b/core/modules/config/config.admin.css new file mode 100644 index 0000000..e42b77c --- /dev/null +++ b/core/modules/config/config.admin.css @@ -0,0 +1,28 @@ +/** + * @file + * Styles for configuration administration page. + */ + + +/** + * Config synchronization form. + */ +table.config-sync-form td { + padding: 6px; + vertical-align: top; +} + +table.config-sync-form td.status-icon { + background: no-repeat center; + height: 16px; + width: 16px; +} + +table.config-sync-form tr.warning { + background-color: #fffce5; +} + +table.config-sync-form tr.warning td.status-icon { + background-image: url(../../misc/message-16-warning.png); + margin: 6px; +} diff --git a/core/modules/config/config.admin.inc b/core/modules/config/config.admin.inc index 3813885..70900e5 100644 --- a/core/modules/config/config.admin.inc +++ b/core/modules/config/config.admin.inc @@ -18,8 +18,12 @@ * The source storage to retrieve differences from. * @param Drupal\Core\Config\StorageInterface $target_storage * The target storage to compare differences to. + * @param Drupal\Core\Config\StorageInterface $snapshot_storage + * The snapshot storage to compare differences to. */ -function config_admin_sync_form(array &$form, array &$form_state, StorageInterface $source_storage, StorageInterface $target_storage) { +function config_admin_sync_form(array &$form, array &$form_state, StorageInterface $source_storage, StorageInterface $target_storage, StorageInterface $snapshot_storage) { + $form['#attached']['css'][] = drupal_get_path('module', 'config') . '/config.admin.css'; + $source_list = $source_storage->listAll(); if (empty($source_list)) { $form['no_changes'] = array( @@ -37,10 +41,21 @@ function config_admin_sync_form(array &$form, array &$form_state, StorageInterfa return $form; } + // Get a list of config that has changed in active since the last snapshot. + // The only purpose of this is to warn users that changes to active config + // could be overwritten, so we don't care about add or delete (which should + // always come back empty which is fine). + $snapshot_changes = config_sync_get_changes($snapshot_storage, $target_storage); + $snapshot_changes['change'] = array_intersect($config_changes['change'], $snapshot_changes['change']); + if (!empty($snapshot_changes['change'])) { + drupaL_set_message("The configuration flagged below has changed since the last time you synchronized. If you continue, local changes to that configuration may be overwritten. You can view the changes by choosing 'View differences since last sync' under 'Operations'", 'warning'); + } + foreach ($config_changes as $config_change_type => $config_files) { if (empty($config_files)) { continue; } + // @todo A table caption would be more appropriate, but does not have the // visual importance of a heading. $form[$config_change_type]['heading'] = array( @@ -62,10 +77,43 @@ function config_admin_sync_form(array &$form, array &$form_state, StorageInterfa } $form[$config_change_type]['list'] = array( '#theme' => 'table', - '#header' => array('Name'), + '#header' => array('','Name', 'Operations'), + '#attributes' => array('class' => array('config-sync-form')), ); + foreach ($config_files as $config_file) { - $form[$config_change_type]['list']['#rows'][] = array($config_file); + $row = array(); + $links = array(); + $links['view_diff'] = array( + 'title' => t('View differences between active and staging'), + 'href' => 'admin/config/development/sync/diff/' . $config_file, + 'ajax' => array('dialog' => array('modal' =>TRUE, 'width' => '700px')), + ); + + if (in_array($config_file, $snapshot_changes[$config_change_type])) { + $row['class'] = array('warning'); + $links['view_snapshot_diff'] = array( + 'title' => t('View differences between active and last import'), + 'href' => 'admin/config/development/sync/diff/' . $config_file, + 'ajax' => array('dialog' => array('modal' =>TRUE, 'width' => '700px')), + ); + } + $row['data'] = array( + 'status' => array( + 'class' => array('status-icon'), + ), + 'name' => $config_file, + 'operations' => array( + 'data' => array( + array( + '#type' => 'operations', + '#links' => $links, + ), + ), + ), + ); + + $form[$config_change_type]['list']['#rows'][] = $row; } } } @@ -80,14 +128,14 @@ function config_admin_import_form($form, &$form_state) { // Retrieve a list of differences between last known state and active store. $source_storage = drupal_container()->get('config.storage.staging'); $target_storage = drupal_container()->get('config.storage'); - + $snapshot_storage = drupal_container()->get('config.storage.snapshot'); $form['actions'] = array('#type' => 'actions'); $form['actions']['submit'] = array( '#type' => 'submit', '#value' => t('Import all'), ); - config_admin_sync_form($form, $form_state, $source_storage, $target_storage); + config_admin_sync_form($form, $form_state, $source_storage, $target_storage, $snapshot_storage); return $form; } @@ -116,3 +164,54 @@ function config_admin_import_form_submit($form, &$form_state) { drupal_set_message(t('The import failed due to an error. Any errors have been logged.'), 'error'); } } + +/** + * Page callback: Shows diff of specificed configuration file. + * + * @param string $config_file + * The name of the configuration file. + * + * @return string + * Table showing a two-way diff between the active and staged configuration. + */ +function config_admin_diff_page($config_file) { + // Retrieve a list of differences between last known state and active store. + $source_storage = drupal_container()->get('config.storage.staging'); + $target_storage = drupal_container()->get('config.storage'); + + // Add the CSS for the inline diff. + $output['#attached']['css'][] = drupal_get_path('module', 'system') . '/system.diff.css'; + + $output['title'] = array( + '#theme' => 'html_tag', + '#tag' => 'h3', + '#value' => t('View changes of @config_file', array('@config_file' => $config_file)), + ); + + $diff = config_diff($target_storage, $source_storage, $config_file); + $formatter = new DrupalDiffFormatter(); + $formatter->show_header = FALSE; + + $variables = array( + 'header' => array( + array('data' => t('Old'), 'colspan' => '2'), + array('data' => t('New'), 'colspan' => '2'), + ), + 'rows' => $formatter->format($diff), + ); + + $output['diff'] = array( + '#markup' => theme('table', $variables), + ); + + $output['back'] = array( + '#type' => 'link', + '#title' => "Back to 'Synchronize configuration' page.", + '#href' => 'admin/config/development/sync', + '#attributes' => array( + 'class' => array('dialog-cancel'), + ), + ); + + return $output; +} diff --git a/core/modules/config/config.module b/core/modules/config/config.module index f8a1874..e3b6027 100644 --- a/core/modules/config/config.module +++ b/core/modules/config/config.module @@ -48,6 +48,14 @@ function config_menu() { 'access arguments' => array('synchronize configuration'), 'file' => 'config.admin.inc', ); + $items['admin/config/development/sync/diff/%'] = array( + 'title' => 'Configuration file diff', + 'description' => 'Diff between active and staged configuraiton.', + 'page callback' => 'config_admin_diff_page', + 'page arguments' => array(5), + 'access arguments' => array('synchronize configuration'), + 'file' => 'config.admin.inc', + ); $items['admin/config/development/sync/import'] = array( 'title' => 'Import', 'type' => MENU_DEFAULT_LOCAL_TASK, diff --git a/core/modules/config/lib/Drupal/config/Tests/ConfigDiffTest.php b/core/modules/config/lib/Drupal/config/Tests/ConfigDiffTest.php new file mode 100644 index 0000000..09f126b --- /dev/null +++ b/core/modules/config/lib/Drupal/config/Tests/ConfigDiffTest.php @@ -0,0 +1,89 @@ + 'Diff functionality', + 'description' => 'Calculating the difference between two sets of configuration.', + 'group' => 'Configuration', + ); + } + + /** + * Tests calculating the difference between two sets of configuration. + */ + function testDiff() { + $active = $this->container->get('config.storage'); + $staging = $this->container->get('config.storage.staging'); + $config_name = 'config_test.system'; + $change_key = 'foo'; + $remove_key = '404'; + $add_key = 'biff'; + $add_data = 'bangpow'; + $change_data = 'foobar'; + $original_data = array( + 'foo' => 'bar', + '404' => 'herp', + ); + + // Install the default config. + config_install_default_config('module', 'config_test'); + + // Change a configuration value in staging. + $staging_data = $original_data; + $staging_data[$change_key] = $change_data; + $staging_data[$add_key] = $add_data; + $staging->write($config_name, $staging_data); + + // Verify that the diff reflects a change. + $diff = config_diff($active, $staging, $config_name); + $this->assertEqual($diff->edits[0]->type, 'change', 'The first item in the diff is a change.'); + $this->assertEqual($diff->edits[0]->orig[0], $change_key . ': ' . $original_data[$change_key], format_string("The active value for key '%change_key' is '%original_data'.", array('%change_key' => $change_key, '%original_data' => $original_data[$change_key]))); + $this->assertEqual($diff->edits[0]->closing[0], $change_key . ': ' . $change_data, format_string("The staging value for key '%change_key' is '%change_data'.", array('%change_key' => $change_key, '%change_data' => $change_data))); + + // Reset data back to original, and remove a key + $staging_data = $original_data; + unset($staging_data[$remove_key]); + $staging->write($config_name, $staging_data); + + // Verify that the diff reflects a removed key. + $diff = config_diff($active, $staging, $config_name); + $this->assertEqual($diff->edits[0]->type, 'copy', 'The first item in the diff is a copy.'); + $this->assertEqual($diff->edits[1]->type, 'delete', 'The second item in the diff is a delete.'); + $this->assertEqual($diff->edits[1]->orig[0], $remove_key . ': ' . $original_data[$remove_key], format_string("The active value for key '%remove_key' is '%original_data'.", array('%remove_key' => $remove_key, '%original_data' => $original_data[$remove_key]))); + $this->assertFalse($diff->edits[1]->closing, format_string("The key '%remove_key' does not exist in staging.", array('%remove_key' => $remove_key))); + + // Reset data back to original and add a key + $staging_data = $original_data; + $staging_data[$add_key] = $add_data; + $staging->write($config_name, $staging_data); + + // Verify that the diff reflects an added key. + $diff = config_diff($active, $staging, $config_name); + $this->assertEqual($diff->edits[0]->type, 'copy', 'The first item in the diff is a copy.'); + $this->assertEqual($diff->edits[1]->type, 'add', 'The second item in the diff is an add.'); + $this->assertFalse($diff->edits[1]->orig, format_string("The key '%add_key' does not exist in active.", array('%add_key' => $add_key))); + $this->assertEqual($diff->edits[1]->closing[0], $add_key . ': ' . $add_data, format_string("The staging value for key '%add_key' is '%add_data'.", array('%add_key' => $add_key, '%add_data' => $add_data))); + } + +} \ No newline at end of file diff --git a/core/modules/config/lib/Drupal/config/Tests/ConfigImportUITest.php b/core/modules/config/lib/Drupal/config/Tests/ConfigImportUITest.php index 7900b7b..3788d67 100644 --- a/core/modules/config/lib/Drupal/config/Tests/ConfigImportUITest.php +++ b/core/modules/config/lib/Drupal/config/Tests/ConfigImportUITest.php @@ -126,6 +126,49 @@ function testImportLock() { $this->assertNotEqual($new_site_name, config('system.site')->get('name')); } + /** + * Tests the screen that shows differences between active and staging. + */ + function testImportDiff() { + $active = $this->container->get('config.storage'); + $staging = $this->container->get('config.storage.staging'); + $config_name = 'config_test.system'; + $change_key = 'foo'; + $remove_key = '404'; + $add_key = 'biff'; + $add_data = 'bangpow'; + $change_data = 'foobar'; + $original_data = array( + 'foo' => 'bar', + '404' => 'herp', + ); + + // Change a configuration value in staging. + $staging_data = $original_data; + $staging_data[$change_key] = $change_data; + $staging_data[$add_key] = $add_data; + $staging->write($config_name, $staging_data); + + // Load the diff UI and verify that the diff reflects the change. + $this->drupalGet('admin/config/development/sync/diff/' . $config_name); + + // Reset data back to original, and remove a key + $staging_data = $original_data; + unset($staging_data[$remove_key]); + $staging->write($config_name, $staging_data); + + // Load the diff UI and verify that the diff reflects a removed key. + $this->drupalGet('admin/config/development/sync/diff/' . $config_name); + + // Reset data back to original and add a key + $staging_data = $original_data; + $staging_data[$add_key] = $add_data; + $staging->write($config_name, $staging_data); + + // Load the diff UI and verify that the diff reflects an added key. + $this->drupalGet('admin/config/development/sync/diff/' . $config_name); + } + function prepareSiteNameUpdate($new_site_name) { $staging = $this->container->get('config.storage.staging'); // Create updated configuration object. diff --git a/core/modules/system/system.diff.css b/core/modules/system/system.diff.css new file mode 100644 index 0000000..1c73598 --- /dev/null +++ b/core/modules/system/system.diff.css @@ -0,0 +1,83 @@ +/** + * Inline diff metadata + */ +.diff-inline-metadata { + padding:4px; + border:1px solid #ddd; + background:#fff; + margin:0px 0px 10px; +} + +.diff-inline-legend { font-size:11px; } + +.diff-inline-legend span, +.diff-inline-legend label { margin-right:5px; } + +/** + * Inline diff markup + */ +span.diff-deleted { color:#ccc; } +span.diff-deleted img { border: solid 2px #ccc; } +span.diff-changed { background:#ffb; } +span.diff-changed img { border:solid 2px #ffb; } +span.diff-added { background:#cfc; } +span.diff-added img { border: solid 2px #cfc; } + +/** + * Traditional split diff theming + */ +table.diff { + border-spacing: 4px; + margin-bottom: 20px; + table-layout: fixed; + width: 100%; +} +table.diff tr.even, table.diff tr.odd { + background-color: inherit; + border: none; +} +td.diff-prevlink { + text-align: left; +} +td.diff-nextlink { + text-align: right; +} +td.diff-section-title, div.diff-section-title { + background-color: #f0f0ff; + font-size: 0.83em; + font-weight: bold; + padding: 0.1em 1em; +} +td.diff-context { + background-color: #fafafa; +} +td.diff-deletedline { + background-color: #ffa; + width: 50%; +} +td.diff-addedline { + background-color: #afa; + width: 50%; +} +span.diffchange { + color: #f00; + font-weight: bold; +} + +table.diff col.diff-marker { + width: 1.4em; +} +table.diff col.diff-content { + width: 50%; +} +table.diff th { + padding-right: inherit; +} +table.diff td div { + overflow: auto; + padding: 0.1ex 0.5em; + word-wrap: break-word; +} +table.diff td { + padding: 0.1ex 0.4em; +}