diff --git a/core/modules/config/config.admin.inc b/core/modules/config/config.admin.inc
new file mode 100644
index 0000000..e3f456c
--- /dev/null
+++ b/core/modules/config/config.admin.inc
@@ -0,0 +1,146 @@
+ t('There are no configuration changes.'),
+ );
+ return $form;
+ }
+
+ 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(
+ '#theme' => 'html_tag__h3',
+ '#tag' => 'h3',
+ );
+ switch ($config_change_type) {
+ case 'create':
+ $form[$config_change_type]['heading']['#value'] = format_plural(count($config_files), '@count new', '@count new');
+ break;
+
+ case 'change':
+ $form[$config_change_type]['heading']['#value'] = format_plural(count($config_files), '@count changed', '@count changed');
+ break;
+
+ case 'delete':
+ $form[$config_change_type]['heading']['#value'] = format_plural(count($config_files), '@count removed', '@count removed');
+ break;
+ }
+ $form[$config_change_type]['list'] = array(
+ '#theme' => 'table',
+ '#header' => array('Name'),
+ );
+ foreach ($config_files as $config_file) {
+ $form[$config_change_type]['list']['#rows'][] = array($config_file);
+ }
+ }
+}
+
+/**
+ * Form constructor for configuration import form.
+ *
+ * @see config_admin_import_form_submit()
+ * @see config_import()
+ */
+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');
+
+ // Prevent users from deleting all configuration.
+ // If the source storage is empty, that signals the unique condition of not
+ // having exported anything at all, and thus no valid storage to compare the
+ // active storage against.
+ // @todo StorageInterface::listAll() can easily yield hundreds or even
+ // thousands of entries; consider to add a dedicated isEmpty() method for
+ // storage controllers.
+ $all = $source_storage->listAll();
+ if (empty($all)) {
+ form_set_error('', t('There is no base configuration. Export it first.', array(
+ '@export-url' => url('admin/config/development/sync/export'),
+ )));
+ return $form;
+ }
+
+ config_admin_sync_form($form, $form_state, $source_storage, $target_storage);
+
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Import all'),
+ );
+ return $form;
+}
+
+/**
+ * Form submission handler for config_admin_import_form().
+ */
+function config_admin_import_form_submit($form, &$form_state) {
+ if (config_import()) {
+ drupal_set_message(t('The configuration was imported successfully.'));
+ }
+ else {
+ // Another request may be synchronizing configuration already. Wait for it
+ // to complete before returning the error, so already synchronized changes
+ // do not appear again.
+ lock_wait(__FUNCTION__);
+ drupal_set_message(t('The import failed due to an error. Any errors have been logged.'), 'error');
+ }
+}
+
+/**
+ * Form constructor for configuration export form.
+ *
+ * @see config_admin_export_form_submit()
+ * @see config_export()
+ *
+ * @todo "export" is a misnomer with config.storage.staging.
+ */
+function config_admin_export_form($form, &$form_state) {
+ // Retrieve a list of differences between active store and last known state.
+ $source_storage = drupal_container()->get('config.storage');
+ $target_storage = drupal_container()->get('config.storage.staging');
+
+ config_admin_sync_form($form, $form_state, $source_storage, $target_storage);
+
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Export all'),
+ );
+ return $form;
+}
+
+/**
+ * Form submission handler for config_admin_export_form().
+ */
+function config_admin_export_form_submit($form, &$form_state) {
+ config_export();
+ drupal_set_message(t('The configuration was exported successfully.'));
+}
+
diff --git a/core/modules/config/config.info b/core/modules/config/config.info
index 380f17e..efab7a1 100644
--- a/core/modules/config/config.info
+++ b/core/modules/config/config.info
@@ -3,3 +3,4 @@ description = Allows administrators to manage configuration changes.
package = Core
version = VERSION
core = 8.x
+configure = admin/config/development/sync
diff --git a/core/modules/config/config.module b/core/modules/config/config.module
index b3d9bbc..deb847d 100644
--- a/core/modules/config/config.module
+++ b/core/modules/config/config.module
@@ -1 +1,68 @@
' . t('About') . '';
+ $output .= '
' . t('The Configuration manager module provides a user interface for importing and exporting configuration changes; i.e., for staging configuration data between multiple instances of this web site. For more information, see the online handbook entry for Configuration manager module', array(
+ '!url' => 'http://drupal.org/documentation/modules/config',
+ )) . '
';
+ return $output;
+
+ case 'admin/config/development/sync':
+ $output = '';
+ $output .= '' . t('Import configuration that is placed in @staging-filepath
. All changes, deletions, renames, and additions are listed below.', array(
+ '@staging-filepath' => config_get_config_directory(CONFIG_STAGING_DIRECTORY),
+ )) . '
';
+ return $output;
+ }
+}
+
+/**
+ * Implements hook_permission().
+ */
+function config_permission() {
+ $permissions['synchronize configuration'] = array(
+ 'title' => t('Synchronize configuration'),
+ 'restrict access' => TRUE,
+ );
+ return $permissions;
+}
+
+/**
+ * Implements hook_menu().
+ */
+function config_menu() {
+ $items['admin/config/development/sync'] = array(
+ 'title' => 'Synchronize configuration',
+ 'description' => 'Synchronize configuration changes.',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('config_admin_import_form'),
+ 'access arguments' => array('synchronize configuration'),
+ 'file' => 'config.admin.inc',
+ );
+ $items['admin/config/development/sync/import'] = array(
+ 'title' => 'Import',
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ 'weight' => -10,
+ );
+ $items['admin/config/development/sync/export'] = array(
+ 'title' => 'Export',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('config_admin_export_form'),
+ 'access arguments' => array('synchronize configuration'),
+ 'file' => 'config.admin.inc',
+ 'type' => MENU_LOCAL_TASK,
+ );
+ return $items;
+}
+
diff --git a/core/modules/config/lib/Drupal/config/Tests/ConfigImportUITest.php b/core/modules/config/lib/Drupal/config/Tests/ConfigImportUITest.php
new file mode 100644
index 0000000..f3fd97f
--- /dev/null
+++ b/core/modules/config/lib/Drupal/config/Tests/ConfigImportUITest.php
@@ -0,0 +1,153 @@
+ 'Import/Export UI',
+ 'description' => 'Tests the user interface for importing/exporting configuration.',
+ 'group' => 'Configuration',
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+
+ $this->web_user = $this->drupalCreateUser(array('synchronize configuration'));
+ $this->drupalLogin($this->web_user);
+ }
+
+ /**
+ * Tests exporting configuration.
+ */
+ function testExport() {
+ $name = 'config_test.system';
+ $dynamic_name = 'config_test.dynamic.default';
+
+ // Verify the default configuration values exist.
+ $config = config($name);
+ $this->assertIdentical($config->get('foo'), 'bar');
+ $config = config($dynamic_name);
+ $this->assertIdentical($config->get('id'), 'default');
+
+ // Verify that both appear as deleted by default.
+ $this->drupalGet('admin/config/development/sync/export');
+ $this->assertText($name);
+ $this->assertText($dynamic_name);
+
+ // Export and verify that both do not appear anymore.
+ $this->drupalPost(NULL, array(), t('Export all'));
+ $this->assertUrl('admin/config/development/sync/export');
+ $this->assertNoText($name);
+ $this->assertNoText($dynamic_name);
+
+ // Verify that there are no further changes to export.
+ $this->assertText(t('There are no configuration changes.'));
+
+ // Verify that the import screen shows no changes either.
+ $this->drupalGet('admin/config/development/sync');
+ $this->assertText(t('There are no configuration changes.'));
+ }
+
+ /**
+ * Tests importing configuration.
+ */
+ function testImport() {
+ $name = 'config_test.new';
+ $dynamic_name = 'config_test.dynamic.new';
+ $storage = $this->container->get('config.storage');
+ $staging = $this->container->get('config.storage.staging');
+
+ // Verify the configuration to create does not exist yet.
+ $this->assertIdentical($storage->exists($name), FALSE, $name . ' not found.');
+ $this->assertIdentical($storage->exists($dynamic_name), FALSE, $dynamic_name . ' not found.');
+ $this->assertIdentical($staging->exists($name), FALSE, $name . ' not found.');
+ $this->assertIdentical($staging->exists($dynamic_name), FALSE, $dynamic_name . ' not found.');
+
+ // Verify that the import UI does not allow to import without exported
+ // configuration.
+ $this->drupalGet('admin/config/development/sync');
+ $this->assertText('There is no base configuration.');
+
+ // Verify that the Export link yields to the export UI page, and export.
+ $this->clickLink('Export');
+ $this->drupalPost(NULL, array(), t('Export all'));
+
+ // Create new configuration objects.
+ $original_name_data = array(
+ 'add_me' => 'new value',
+ );
+ $staging->write($name, $original_name_data);
+ $original_dynamic_data = array(
+ 'id' => 'new',
+ 'label' => 'New',
+ 'langcode' => 'und',
+ 'style' => '',
+ 'uuid' => '30df59bd-7b03-4cf7-bb35-d42fc49f0651',
+ );
+ $staging->write($dynamic_name, $original_dynamic_data);
+ $this->assertIdentical($staging->exists($name), TRUE, $name . ' found.');
+ $this->assertIdentical($staging->exists($dynamic_name), TRUE, $dynamic_name . ' found.');
+
+ // Verify that both appear as new.
+ $this->drupalGet('admin/config/development/sync');
+ $this->assertText($name);
+ $this->assertText($dynamic_name);
+
+ // Import and verify that both do not appear anymore.
+ $this->drupalPost(NULL, array(), t('Import all'));
+ $this->assertUrl('admin/config/development/sync');
+ $this->assertNoText($name);
+ $this->assertNoText($dynamic_name);
+
+ // Verify that there are no further changes to import.
+ $this->assertText(t('There are no configuration changes.'));
+
+ // Verify that the export screen shows no changes either.
+ $this->drupalGet('admin/config/development/sync/export');
+ $this->assertText(t('There are no configuration changes.'));
+ }
+
+ /**
+ * Tests concurrent importing of configuration.
+ */
+ function testImportLock() {
+ $name = 'config_test.new';
+ $staging = $this->container->get('config.storage.staging');
+
+ // Write a configuration object to import.
+ $staging->write($name, array(
+ 'add_me' => 'new value',
+ ));
+
+ // Verify that there are configuration differences to import.
+ $this->drupalGet('admin/config/development/sync');
+ $this->assertNoText(t('There are no configuration changes.'));
+
+ // Acquire a fake-lock on the import mechanism.
+ $lock_name = 'config_import';
+ lock_acquire($lock_name);
+
+ // Attempt to import configuration and verify that an error message appears.
+ $this->drupalPost(NULL, array(), t('Import all'));
+ $this->assertUrl('admin/config/development/sync');
+ $this->assertText(t('The import failed due to an error. Any errors have been logged.'));
+
+ // Release the lock, just to keep testing sane.
+ lock_release($lock_name);
+ }
+}
diff --git a/core/profiles/standard/standard.info b/core/profiles/standard/standard.info
index 8b8a33b..c5240dd 100644
--- a/core/profiles/standard/standard.info
+++ b/core/profiles/standard/standard.info
@@ -6,6 +6,7 @@ dependencies[] = node
dependencies[] = block
dependencies[] = color
dependencies[] = comment
+dependencies[] = config
dependencies[] = contextual
dependencies[] = help
dependencies[] = image