diff --git a/core/composer.json b/core/composer.json index 15fa324..1a27140 100644 --- a/core/composer.json +++ b/core/composer.json @@ -61,6 +61,7 @@ "drupal/config": "self.version", "drupal/config_translation": "self.version", "drupal/contact": "self.version", + "drupal/content_moderation": "self.version", "drupal/content_translation": "self.version", "drupal/contextual": "self.version", "drupal/core-annotation": "self.version", diff --git a/core/includes/file.inc b/core/includes/file.inc index 017a3d6..e6490e9 100644 --- a/core/includes/file.inc +++ b/core/includes/file.inc @@ -412,7 +412,7 @@ function file_valid_uri($uri) { } /** - * Copies a file to a new location without database changes or hook invocation. + * Copies a file to a new location without invoking the file API. * * This is a powerful function that in many ways performs like an advanced * version of copy(). @@ -422,9 +422,10 @@ function file_valid_uri($uri) { * - If the $source and $destination are equal, the behavior depends on the * $replace parameter. FILE_EXISTS_REPLACE will error out. FILE_EXISTS_RENAME * will rename the file until the $destination is unique. - * - Works around a PHP bug where copy() does not properly support streams if - * safe_mode or open_basedir are enabled. - * @see https://bugs.php.net/bug.php?id=60456 + * - Provides a fallback using realpaths if the move fails using stream + * wrappers. This can occur because PHP's copy() function does not properly + * support streams if open_basedir is enabled. See + * https://bugs.php.net/bug.php?id=60456 * * @param $source * A string specifying the filepath or URI of the source file. @@ -445,66 +446,18 @@ function file_valid_uri($uri) { * @see file_copy() */ function file_unmanaged_copy($source, $destination = NULL, $replace = FILE_EXISTS_RENAME) { - if (!file_unmanaged_prepare($source, $destination, $replace)) { - return FALSE; - } - // Attempt to resolve the URIs. This is necessary in certain configurations - // (see above). - $real_source = drupal_realpath($source) ?: $source; - $real_destination = drupal_realpath($destination) ?: $destination; - // Perform the copy operation. - if (!@copy($real_source, $real_destination)) { - \Drupal::logger('file')->error('The specified file %file could not be copied to %destination.', array('%file' => $source, '%destination' => $destination)); - return FALSE; - } - // Set the permissions on the new file. - drupal_chmod($destination); - return $destination; -} - -/** - * Internal function that prepares the destination for a file_unmanaged_copy or - * file_unmanaged_move operation. - * - * - Checks if $source and $destination are valid and readable/writable. - * - Checks that $source is not equal to $destination; if they are an error - * is reported. - * - If file already exists in $destination either the call will error out, - * replace the file or rename the file based on the $replace parameter. - * - * @param $source - * A string specifying the filepath or URI of the source file. - * @param $destination - * A URI containing the destination that $source should be moved/copied to. - * The URI may be a bare filepath (without a scheme) and in that case the - * default scheme (file://) will be used. If this value is omitted, Drupal's - * default files scheme will be used, usually "public://". - * @param $replace - * Replace behavior when the destination file already exists: - * - FILE_EXISTS_REPLACE - Replace the existing file. - * - FILE_EXISTS_RENAME - Append _{incrementing number} until the filename is - * unique. - * - FILE_EXISTS_ERROR - Do nothing and return FALSE. - * - * @return - * TRUE, or FALSE in the event of an error. - * - * @see file_unmanaged_copy() - * @see file_unmanaged_move() - */ -function file_unmanaged_prepare($source, &$destination = NULL, $replace = FILE_EXISTS_RENAME) { $original_source = $source; $logger = \Drupal::logger('file'); // Assert that the source file actually exists. if (!file_exists($source)) { // @todo Replace drupal_set_message() calls with exceptions instead. - drupal_set_message(t('The specified file %file could not be moved/copied because no file by that name exists. Please check that you supplied the correct filename.', array('%file' => $original_source)), 'error'); + drupal_set_message(t('The specified file %file could not be copied because no file by that name exists. Please check that you supplied the correct filename.', array('%file' => $original_source)), 'error'); if (($realpath = drupal_realpath($original_source)) !== FALSE) { - $logger->notice('File %file (%realpath) could not be moved/copied because it does not exist.', array('%file' => $original_source, '%realpath' => $realpath)); + $logger->notice('File %file (%realpath) could not be copied because it does not exist.', array('%file' => $original_source, '%realpath' => $realpath)); } else { - $logger->notice('File %file could not be moved/copied because it does not exist.', array('%file' => $original_source)); + $logger->notice('File %file could not be copied because it does not exist.', array('%file' => $original_source)); } return FALSE; } @@ -525,8 +478,8 @@ function file_unmanaged_prepare($source, &$destination = NULL, $replace = FILE_E $dirname = drupal_dirname($destination); if (!file_prepare_directory($dirname)) { // The destination is not valid. - $logger->notice('File %file could not be moved/copied because the destination directory %destination is not configured correctly.', array('%file' => $original_source, '%destination' => $dirname)); - drupal_set_message(t('The specified file %file could not be moved/copied because the destination directory is not properly configured. This may be caused by a problem with file or directory permissions. More information is available in the system log.', array('%file' => $original_source)), 'error'); + $logger->notice('File %file could not be copied because the destination directory %destination is not configured correctly.', array('%file' => $original_source, '%destination' => $dirname)); + drupal_set_message(t('The specified file %file could not be copied because the destination directory is not properly configured. This may be caused by a problem with file or directory permissions. More information is available in the system log.', array('%file' => $original_source)), 'error'); return FALSE; } } @@ -534,8 +487,8 @@ function file_unmanaged_prepare($source, &$destination = NULL, $replace = FILE_E // Determine whether we can perform this operation based on overwrite rules. $destination = file_destination($destination, $replace); if ($destination === FALSE) { - drupal_set_message(t('The file %file could not be moved/copied because a file by that name already exists in the destination directory.', array('%file' => $original_source)), 'error'); - $logger->notice('File %file could not be moved/copied because a file by that name already exists in the destination directory (%destination)', array('%file' => $original_source, '%destination' => $destination)); + drupal_set_message(t('The file %file could not be copied because a file by that name already exists in the destination directory.', array('%file' => $original_source)), 'error'); + $logger->notice('File %file could not be copied because a file by that name already exists in the destination directory (%destination)', array('%file' => $original_source, '%destination' => $destination)); return FALSE; } @@ -543,13 +496,26 @@ function file_unmanaged_prepare($source, &$destination = NULL, $replace = FILE_E $real_source = drupal_realpath($source); $real_destination = drupal_realpath($destination); if ($source == $destination || ($real_source !== FALSE) && ($real_source == $real_destination)) { - drupal_set_message(t('The specified file %file was not moved/copied because it would overwrite itself.', array('%file' => $source)), 'error'); - $logger->notice('File %file could not be moved/copied because it would overwrite itself.', array('%file' => $source)); + drupal_set_message(t('The specified file %file was not copied because it would overwrite itself.', array('%file' => $source)), 'error'); + $logger->notice('File %file could not be copied because it would overwrite itself.', array('%file' => $source)); return FALSE; } // Make sure the .htaccess files are present. file_ensure_htaccess(); - return TRUE; + // Perform the copy operation. + if (!@copy($source, $destination)) { + // If the copy failed and realpaths exist, retry the operation using them + // instead. + if ($real_source === FALSE || $real_destination === FALSE || !@copy($real_source, $real_destination)) { + $logger->error('The specified file %file could not be copied to %destination.', array('%file' => $source, '%destination' => $destination)); + return FALSE; + } + } + + // Set the permissions on the new file. + drupal_chmod($destination); + + return $destination; } /** @@ -600,24 +566,12 @@ function file_destination($destination, $replace) { /** * Moves a file to a new location without database changes or hook invocation. * - * This is a powerful function that in many ways performs like an advanced - * version of rename(). - * - Checks if $source and $destination are valid and readable/writable. - * - Checks that $source is not equal to $destination; if they are an error - * is reported. - * - If file already exists in $destination either the call will error out, - * replace the file or rename the file based on the $replace parameter. - * - Works around a PHP bug where rename() does not properly support streams if - * safe_mode or open_basedir are enabled. - * @see https://bugs.php.net/bug.php?id=60456 - * * @param $source - * A string specifying the filepath or URI of the source file. + * A string specifying the filepath or URI of the original file. * @param $destination - * A URI containing the destination that $source should be moved to. The - * URI may be a bare filepath (without a scheme) and in that case the default - * scheme (file://) will be used. If this value is omitted, Drupal's default - * files scheme will be used, usually "public://". + * A string containing the destination that $source should be moved to. + * This must be a stream wrapper URI. If this value is omitted, Drupal's + * default files scheme will be used, usually "public://". * @param $replace * Replace behavior when the destination file already exists: * - FILE_EXISTS_REPLACE - Replace the existing file. @@ -626,37 +580,16 @@ function file_destination($destination, $replace) { * - FILE_EXISTS_ERROR - Do nothing and return FALSE. * * @return - * The path to the new file, or FALSE in the event of an error. + * The URI of the moved file, or FALSE in the event of an error. * * @see file_move() */ function file_unmanaged_move($source, $destination = NULL, $replace = FILE_EXISTS_RENAME) { - if (!file_unmanaged_prepare($source, $destination, $replace)) { + $filepath = file_unmanaged_copy($source, $destination, $replace); + if ($filepath == FALSE || file_unmanaged_delete($source) == FALSE) { return FALSE; } - // Ensure compatibility with Windows. - // @see drupal_unlink() - if ((substr(PHP_OS, 0, 3) == 'WIN') && (!file_stream_wrapper_valid_scheme(file_uri_scheme($source)))) { - chmod($source, 0600); - } - // Attempt to resolve the URIs. This is necessary in certain configurations - // (see above) and can also permit fast moves across local schemes. - $real_source = drupal_realpath($source) ?: $source; - $real_destination = drupal_realpath($destination) ?: $destination; - // Perform the move operation. - if (!@rename($real_source, $real_destination)) { - // Fall back to slow copy and unlink procedure. This is necessary for - // renames across schemes that are not local, or where rename() has not been - // implemented. It's not necessary to use drupal_unlink() as the Windows - // issue has already been resolved above. - if (!@copy($real_source, $real_destination) || !@unlink($real_source)) { - \Drupal::logger('file')->error('The specified file %file could not be moved to %destination.', array('%file' => $source, '%destination' => $destination)); - return FALSE; - } - } - // Set the permissions on the new file. - drupal_chmod($destination); - return $destination; + return $filepath; } /** diff --git a/core/includes/theme.inc b/core/includes/theme.inc index 3fd6b0a..9b89250 100644 --- a/core/includes/theme.inc +++ b/core/includes/theme.inc @@ -134,8 +134,7 @@ function drupal_theme_rebuild() { */ function drupal_find_theme_functions($cache, $prefixes) { $implementations = []; - $functions = get_defined_functions(); - $theme_functions = preg_grep('/^(' . implode(')|(', $prefixes) . ')_/', $functions['user']); + $grouped_functions = \Drupal::service('theme.registry')->getPrefixGroupedUserFunctions(); foreach ($cache as $hook => $info) { foreach ($prefixes as $prefix) { @@ -151,8 +150,10 @@ function drupal_find_theme_functions($cache, $prefixes) { // are found using the base hook's pattern, not a pattern from an // intermediary suggestion. $pattern = isset($info['pattern']) ? $info['pattern'] : ($hook . '__'); - if (!isset($info['base hook']) && !empty($pattern)) { - $matches = preg_grep('/^' . $prefix . '_' . $pattern . '/', $theme_functions); + // Grep only the functions which are within the prefix group. + list($first_prefix,) = explode('_', $prefix, 2); + if (!isset($info['base hook']) && !empty($pattern) && isset($grouped_functions[$first_prefix])) { + $matches = preg_grep('/^' . $prefix . '_' . $pattern . '/', $grouped_functions[$first_prefix]); if ($matches) { foreach ($matches as $match) { $new_hook = substr($match, strlen($prefix) + 1); diff --git a/core/lib/Drupal/Core/Cache/ChainedFastBackend.php b/core/lib/Drupal/Core/Cache/ChainedFastBackend.php index 5c0750d..62839c2 100644 --- a/core/lib/Drupal/Core/Cache/ChainedFastBackend.php +++ b/core/lib/Drupal/Core/Cache/ChainedFastBackend.php @@ -87,16 +87,8 @@ class ChainedFastBackend implements CacheBackendInterface, CacheTagsInvalidatorI * The fast cache backend. * @param string $bin * The cache bin for which the object is created. - * - * @throws \Exception - * When the consistent cache backend and the fast cache backend are the same - * service. */ public function __construct(CacheBackendInterface $consistent_backend, CacheBackendInterface $fast_backend, $bin) { - if ($consistent_backend == $fast_backend) { - // @todo: should throw a proper exception. See https://www.drupal.org/node/2751847. - trigger_error('Consistent cache backend and fast cache backend cannot use the same service.', E_USER_ERROR); - } $this->consistentBackend = $consistent_backend; $this->fastBackend = $fast_backend; $this->bin = 'cache_' . $bin; diff --git a/core/modules/block/migration_templates/d7_block.yml b/core/modules/block/migration_templates/d7_block.yml index ff56111..32e8f5d 100644 --- a/core/modules/block/migration_templates/d7_block.yml +++ b/core/modules/block/migration_templates/d7_block.yml @@ -19,9 +19,6 @@ process: - module - delta delimiter: _ - - - plugin: machine_name - field: id plugin: - plugin: static_map diff --git a/core/modules/block/tests/src/Kernel/Migrate/d7/MigrateBlockTest.php b/core/modules/block/tests/src/Kernel/Migrate/d7/MigrateBlockTest.php index 571c210..9c3ff5f 100644 --- a/core/modules/block/tests/src/Kernel/Migrate/d7/MigrateBlockTest.php +++ b/core/modules/block/tests/src/Kernel/Migrate/d7/MigrateBlockTest.php @@ -108,7 +108,7 @@ public function testBlockMigration() { $this->assertEntity('bartik_system_main', 'system_main_block', [], '', 'content', 'bartik', 0, '', '0'); $this->assertEntity('bartik_search_form', 'search_form_block', [], '', 'sidebar_first', 'bartik', -1, '', '0'); $this->assertEntity('bartik_user_login', 'user_login_block', [], '', 'sidebar_first', 'bartik', 0, '', '0'); - $this->assertEntity('bartik_system_powered_by', 'system_powered_by_block', [], '', 'footer', 'bartik', 10, '', '0'); + $this->assertEntity('bartik_system_powered-by', 'system_powered_by_block', [], '', 'footer', 'bartik', 10, '', '0'); $this->assertEntity('seven_system_main', 'system_main_block', [], '', 'content', 'seven', 0, '', '0'); $this->assertEntity('seven_user_login', 'user_login_block', [], '', 'content', 'seven', 10, '', '0'); diff --git a/core/modules/config_translation/migration_templates/d6_i18n_system_site.yml b/core/modules/config_translation/migration_templates/d6_i18n_system_site.yml deleted file mode 100644 index 9c9337c..0000000 --- a/core/modules/config_translation/migration_templates/d6_i18n_system_site.yml +++ /dev/null @@ -1,38 +0,0 @@ -id: d6_i18n_system_site -label: Site configuration -migration_tags: - - Drupal 6 -source: - plugin: i18n_variable - constants: - slash: '/' - variables: - - site_name - - site_mail - - site_slogan - - site_frontpage - - site_403 - - site_404 -process: - langcode: language - name: site_name - mail: site_mail - slogan: site_slogan - 'page/front': - plugin: concat - source: - - constants/slash - - site_frontpage - 'page/403': - plugin: concat - source: - - constants/slash - - site_403 - 'page/404': - plugin: concat - source: - - constants/slash - - site_404 -destination: - plugin: config - config_name: system.site diff --git a/core/modules/config_translation/migration_templates/d6_i18n_user_mail.yml b/core/modules/config_translation/migration_templates/d6_i18n_user_mail.yml deleted file mode 100644 index b3c916f..0000000 --- a/core/modules/config_translation/migration_templates/d6_i18n_user_mail.yml +++ /dev/null @@ -1,68 +0,0 @@ -id: d6_i18n_user_mail -label: User mail configuration -migration_tags: - - Drupal 6 -source: - plugin: i18n_variable - variables: - - user_mail_status_activated_subject - - user_mail_status_activated_body - - user_mail_password_reset_subject - - user_mail_password_reset_body - - user_mail_status_deleted_subject - - user_mail_status_deleted_body - - user_mail_register_admin_created_subject - - user_mail_register_admin_created_body - - user_mail_register_no_approval_required_subject - - user_mail_register_no_approval_required_body - - user_mail_register_pending_approval_subject - - user_mail_register_pending_approval_body - - user_mail_status_blocked_subject - - user_mail_status_blocked_body -process: - langcode: language - 'status_activated/subject': - plugin: convert_tokens - source: user_mail_status_activated_subject - 'status_activated/body': - plugin: convert_tokens - source: user_mail_status_activated_body - 'password_reset/subject': - plugin: convert_tokens - source: user_mail_password_reset_subject - 'password_reset/body': - plugin: convert_tokens - source: user_mail_password_reset_body - 'cancel_confirm/subject': - plugin: convert_tokens - source: user_mail_status_deleted_subject - 'cancel_confirm/body': - plugin: convert_tokens - source: user_mail_status_deleted_body - 'register_admin_created/subject': - plugin: convert_tokens - source: user_mail_register_admin_created_subject - 'register_admin_created/body': - plugin: convert_tokens - source: user_mail_register_admin_created_body - 'register_no_approval_required/subject': - plugin: convert_tokens - source: user_mail_register_no_approval_required_subject - 'register_no_approval_required/body': - plugin: convert_tokens - source: user_mail_register_no_approval_required_body - 'register_pending_approval/subject': - plugin: convert_tokens - source: user_mail_register_pending_approval_subject - 'register_pending_approval/body': - plugin: convert_tokens - source: user_mail_register_pending_approval_body - 'status_blocked/subject': - plugin: convert_tokens - source: user_mail_status_blocked_subject - 'status_blocked/body': - plugin: convert_tokens - source: user_mail_status_blocked_body -destination: - plugin: config - config_name: user.mail diff --git a/core/modules/config_translation/migration_templates/d6_i18n_user_settings.yml b/core/modules/config_translation/migration_templates/d6_i18n_user_settings.yml deleted file mode 100644 index 979a74a..0000000 --- a/core/modules/config_translation/migration_templates/d6_i18n_user_settings.yml +++ /dev/null @@ -1,29 +0,0 @@ -id: d6_i18n_user_settings -label: User configuration -migration_tags: - - Drupal 6 -source: - plugin: i18n_variable - variables: - - user_mail_status_blocked_notify - - user_mail_status_activated_notify - - user_email_verification - - user_register - - anonymous -process: - langcode: language - 'notify/status_blocked': user_mail_status_blocked_notify - 'notify/status_activated': user_mail_status_activated_notify - verify_mail: user_email_verification - register: - plugin: static_map - source: user_register - default_value: visitors_admin_approval - map: - 2: visitors_admin_approval - 1: visitors - 0: admin_only - anonymous: anonymous -destination: - plugin: config - config_name: user.settings diff --git a/core/modules/config_translation/tests/src/Kernel/Migrate/d6/MigrateI18nSystemSiteTest.php b/core/modules/config_translation/tests/src/Kernel/Migrate/d6/MigrateI18nSystemSiteTest.php deleted file mode 100644 index 770b33c..0000000 --- a/core/modules/config_translation/tests/src/Kernel/Migrate/d6/MigrateI18nSystemSiteTest.php +++ /dev/null @@ -1,47 +0,0 @@ -executeMigration('d6_i18n_system_site'); - } - - /** - * Tests migration of system (site) variables to system.site.yml. - */ - public function testSystemSite() { - $config_translation = \Drupal::service('language_manager')->getLanguageConfigOverride('fr', 'system.site'); - $this->assertIdentical('fr site name', $config_translation->get('name')); - $this->assertIdentical('fr_site_mail@example.com', $config_translation->get('mail')); - $this->assertIdentical('fr Migrate rocks', $config_translation->get('slogan')); - $this->assertIdentical('/fr-user', $config_translation->get('page.403')); - $this->assertIdentical('/fr-page-not-found', $config_translation->get('page.404')); - $this->assertIdentical('/node', $config_translation->get('page.front')); - $this->assertIdentical(NULL, $config_translation->get('admin_compact_mode')); - - $config_translation = \Drupal::service('language_manager')->getLanguageConfigOverride('zu', 'system.site'); - $this->assertIdentical('zu - site_name', $config_translation->get('name')); - $this->assertIdentical('site_mail@example.com', $config_translation->get('mail')); - $this->assertIdentical('Migrate rocks', $config_translation->get('slogan')); - $this->assertIdentical('/zu-user', $config_translation->get('page.403')); - $this->assertIdentical('/zu-page-not-found', $config_translation->get('page.404')); - $this->assertIdentical('/node', $config_translation->get('page.front')); - $this->assertIdentical(NULL, $config_translation->get('admin_compact_mode')); - } - -} diff --git a/core/modules/config_translation/tests/src/Kernel/Migrate/d6/MigrateI18nUserConfigsTest.php b/core/modules/config_translation/tests/src/Kernel/Migrate/d6/MigrateI18nUserConfigsTest.php deleted file mode 100644 index b36a547..0000000 --- a/core/modules/config_translation/tests/src/Kernel/Migrate/d6/MigrateI18nUserConfigsTest.php +++ /dev/null @@ -1,74 +0,0 @@ -installSchema('locale', - ['locales_source', 'locales_target', 'locales_location']); - $this->executeMigrations(['d6_i18n_user_mail', 'd6_i18n_user_settings']); - } - - /** - * Tests migration of i18n user variables to user.mail.yml. - */ - public function testUserMail() { - $config = \Drupal::service('language_manager')->getLanguageConfigOverride('fr', 'user.mail'); - $this->assertIdentical('fr - Account details for [user:name] at [site:name] (approved)', $config->get('status_activated.subject')); - $this->assertIdentical("fr - [user:name],\r\n\r\nYour account at [site:name] has been activated.\r\n\r\nYou may now log in by clicking on this link or copying and pasting it in your browser:\r\n\r\n[user:one-time-login-url]\r\n\r\nThis is a one-time login, so it can be used only once.\r\n\r\nAfter logging in, you will be redirected to [user:edit-url] so you can change your password.\r\n\r\nOnce you have set your own password, you will be able to log in to [site:login-url] in the future using:\r\n\r\nusername: [user:name]\r\n", $config->get('status_activated.body')); - $this->assertIdentical('fr - Replacement login information for [user:name] at [site:name]', $config->get('password_reset.subject')); - $this->assertIdentical("fr - [user:name],\r\n\r\nA request to reset the password for your account has been made at [site:name].\r\n\r\nYou may now log in to [site:url-brief] by clicking on this link or copying and pasting it in your browser:\r\n\r\n[user:one-time-login-url]\r\n\r\nThis is a one-time login, so it can be used only once. It expires after one day and nothing will happen if it's not used.\r\n\r\nAfter logging in, you will be redirected to [user:edit-url] so you can change your password.", $config->get('password_reset.body')); - $this->assertIdentical('fr - Account details for [user:name] at [site:name] (deleted)', $config->get('cancel_confirm.subject')); - $this->assertIdentical("fr - [user:name],\r\n\r\nYour account on [site:name] has been deleted.", $config->get('cancel_confirm.body')); - $this->assertIdentical('fr - An administrator created an account for you at [site:name]', $config->get('register_admin_created.subject')); - $this->assertIdentical("fr - [user:name],\r\n\r\nA site administrator at [site:name] has created an account for you. You may now log in to [site:login-url] using the following username and password:\r\n\r\nusername: [user:name]\r\npassword: \r\n\r\nYou may also log in by clicking on this link or copying and pasting it in your browser:\r\n\r\n[user:one-time-login-url]\r\n\r\nThis is a one-time login, so it can be used only once.\r\n\r\nAfter logging in, you will be redirected to [user:edit-url] so you can change your password.\r\n\r\n\r\n-- [site:name] team", $config->get('register_admin_created.body')); - $this->assertIdentical('fr - Account details for [user:name] at [site:name]', $config->get('register_no_approval_required.subject')); - $this->assertIdentical("fr - [user:name],\r\n\r\nThank you for registering at [site:name]. You may now log in to [site:login-url] using the following username and password:\r\n\r\nusername: [user:name]\r\npassword: \r\n\r\nYou may also log in by clicking on this link or copying and pasting it in your browser:\r\n\r\n[user:one-time-login-url]\r\n\r\nThis is a one-time login, so it can be used only once.\r\n\r\nAfter logging in, you will be redirected to [user:edit-url] so you can change your password.\r\n\r\n\r\n-- [site:name] team", $config->get('register_no_approval_required.body')); - $this->assertIdentical('fr - Account details for [user:name] at [site:name] (pending admin approval)', $config->get('register_pending_approval.subject')); - $this->assertIdentical("fr - [user:name],\r\n\r\nThank you for registering at [site:name]. Your application for an account is currently pending approval. Once it has been approved, you will receive another email containing information about how to log in, set your password, and other details.\r\n\r\n\r\n-- [site:name] team", $config->get('register_pending_approval.body')); - $this->assertIdentical('fr - Account details for [user:name] at [site:name] (blocked)', $config->get('status_blocked.subject')); - $this->assertIdentical("fr - [user:name],\r\n\r\nYour account on [site:name] has been blocked.", $config->get('status_blocked.body')); - $this->assertConfigSchema(\Drupal::service('config.typed'), 'user.mail', $config->get()); - - $config = \Drupal::service('language_manager')->getLanguageConfigOverride('zu', 'user.mail'); - $this->assertIdentical('zu - An administrator created an account for you at [site:name]', $config->get('register_admin_created.subject')); - $this->assertIdentical("zu - [user:name],\r\n\r\nA site administrator at [site:name] has created an account for you. You may now log in to [site:login-url] using the following username and password:\r\n\r\nusername: [user:name]\r\npassword: \r\n\r\nYou may also log in by clicking on this link or copying and pasting it in your browser:\r\n\r\n[user:one-time-login-url]\r\n\r\nThis is a one-time login, so it can be used only once.\r\n\r\nAfter logging in, you will be redirected to [user:edit-url] so you can change your password.\r\n\r\n\r\n-- [site:name] team", $config->get('register_admin_created.body')); - } - - /** - * Tests migration of i18n user variables to user.settings.yml. - */ - public function testUserSettings() { - $config = \Drupal::service('language_manager')->getLanguageConfigOverride('fr', 'user.settings'); - $this->assertIdentical(1, $config->get('notify.status_blocked')); - $this->assertIdentical(0, $config->get('notify.status_activated')); - $this->assertIdentical(0, $config->get('verify_mail')); - $this->assertIdentical('admin_only', $config->get('register')); - $this->assertIdentical('fr Guest', $config->get('anonymous')); - - $config = \Drupal::service('language_manager')->getLanguageConfigOverride('zu', 'user.settings'); - $this->assertIdentical(1, $config->get('notify.status_blocked')); - $this->assertIdentical(0, $config->get('notify.status_activated')); - $this->assertIdentical(0, $config->get('verify_mail')); - $this->assertIdentical('admin_only', $config->get('register')); - $this->assertIdentical('Guest', $config->get('anonymous')); - } - -} diff --git a/core/modules/content_moderation/config/install/content_moderation.moderation_state.archived.yml b/core/modules/content_moderation/config/install/content_moderation.moderation_state.archived.yml new file mode 100644 index 0000000..637d792 --- /dev/null +++ b/core/modules/content_moderation/config/install/content_moderation.moderation_state.archived.yml @@ -0,0 +1,7 @@ +langcode: en +status: true +dependencies: { } +id: archived +label: Archived +published: false +default_revision: true diff --git a/core/modules/content_moderation/config/install/content_moderation.moderation_state.draft.yml b/core/modules/content_moderation/config/install/content_moderation.moderation_state.draft.yml new file mode 100644 index 0000000..5c2784a --- /dev/null +++ b/core/modules/content_moderation/config/install/content_moderation.moderation_state.draft.yml @@ -0,0 +1,7 @@ +langcode: en +status: true +dependencies: { } +id: draft +label: Draft +published: false +default_revision: false diff --git a/core/modules/content_moderation/config/install/content_moderation.moderation_state.needs_review.yml b/core/modules/content_moderation/config/install/content_moderation.moderation_state.needs_review.yml new file mode 100644 index 0000000..76a8033 --- /dev/null +++ b/core/modules/content_moderation/config/install/content_moderation.moderation_state.needs_review.yml @@ -0,0 +1,7 @@ +langcode: en +status: true +dependencies: { } +id: needs_review +label: Needs Review +published: false +default_revision: false diff --git a/core/modules/content_moderation/config/install/content_moderation.moderation_state.published.yml b/core/modules/content_moderation/config/install/content_moderation.moderation_state.published.yml new file mode 100644 index 0000000..d40dda7 --- /dev/null +++ b/core/modules/content_moderation/config/install/content_moderation.moderation_state.published.yml @@ -0,0 +1,7 @@ +langcode: en +status: true +dependencies: { } +id: published +label: Published +published: true +default_revision: true diff --git a/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.archived_published.yml b/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.archived_published.yml new file mode 100644 index 0000000..45bce07 --- /dev/null +++ b/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.archived_published.yml @@ -0,0 +1,11 @@ +langcode: en +status: true +dependencies: + config: + - content_moderation.moderation_state.archived + - content_moderation.moderation_state.published +id: archived_published +label: 'Un-archive' +stateFrom: archived +stateTo: published +weight: 0 diff --git a/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.draft_draft.yml b/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.draft_draft.yml new file mode 100644 index 0000000..acca7d2 --- /dev/null +++ b/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.draft_draft.yml @@ -0,0 +1,10 @@ +langcode: en +status: true +dependencies: + config: + - content_moderation.moderation_state.draft +id: draft_draft +label: 'Create New Draft' +stateFrom: draft +stateTo: draft +weight: -11 diff --git a/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.draft_needs_review.yml b/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.draft_needs_review.yml new file mode 100644 index 0000000..bf10d97 --- /dev/null +++ b/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.draft_needs_review.yml @@ -0,0 +1,11 @@ +langcode: en +status: true +dependencies: + config: + - content_moderation.moderation_state.draft + - content_moderation.moderation_state.needs_review +id: draft_needs_review +label: 'Request Review' +stateFrom: draft +stateTo: needs_review +weight: -10 diff --git a/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.draft_published.yml b/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.draft_published.yml new file mode 100644 index 0000000..468c962 --- /dev/null +++ b/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.draft_published.yml @@ -0,0 +1,11 @@ +langcode: en +status: true +dependencies: + config: + - content_moderation.moderation_state.draft + - content_moderation.moderation_state.published +id: draft_published +label: 'Publish' +stateFrom: draft +stateTo: published +weight: -9 diff --git a/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.needs_review_draft.yml b/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.needs_review_draft.yml new file mode 100644 index 0000000..091507d --- /dev/null +++ b/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.needs_review_draft.yml @@ -0,0 +1,11 @@ +langcode: en +status: true +dependencies: + config: + - content_moderation.moderation_state.draft + - content_moderation.moderation_state.needs_review +id: needs_review_draft +label: 'Send Back to Draft' +stateFrom: needs_review +stateTo: draft +weight: -6 diff --git a/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.needs_review_needs_review.yml b/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.needs_review_needs_review.yml new file mode 100644 index 0000000..5ee8dd7 --- /dev/null +++ b/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.needs_review_needs_review.yml @@ -0,0 +1,10 @@ +langcode: en +status: true +dependencies: + config: + - content_moderation.moderation_state.needs_review +id: needs_review_needs_review +label: 'Keep in Review' +stateFrom: needs_review +stateTo: needs_review +weight: -8 diff --git a/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.needs_review_published.yml b/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.needs_review_published.yml new file mode 100644 index 0000000..ccc5262 --- /dev/null +++ b/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.needs_review_published.yml @@ -0,0 +1,11 @@ +langcode: en +status: true +dependencies: + config: + - content_moderation.moderation_state.needs_review + - content_moderation.moderation_state.published +id: needs_review_published +label: 'Publish' +stateFrom: needs_review +stateTo: published +weight: -7 diff --git a/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.published_archived.yml b/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.published_archived.yml new file mode 100644 index 0000000..7022a4d --- /dev/null +++ b/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.published_archived.yml @@ -0,0 +1,11 @@ +langcode: en +status: true +dependencies: + config: + - content_moderation.moderation_state.archived + - content_moderation.moderation_state.published +id: published_archived +label: 'Archive' +stateFrom: published +stateTo: archived +weight: -3 diff --git a/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.published_draft.yml b/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.published_draft.yml new file mode 100644 index 0000000..6a94de4 --- /dev/null +++ b/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.published_draft.yml @@ -0,0 +1,11 @@ +langcode: en +status: true +dependencies: + config: + - content_moderation.moderation_state.draft + - content_moderation.moderation_state.published +id: published_draft +label: 'Create New Draft' +stateFrom: published +stateTo: draft +weight: -5 diff --git a/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.published_published.yml b/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.published_published.yml new file mode 100644 index 0000000..4d9a18e --- /dev/null +++ b/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.published_published.yml @@ -0,0 +1,10 @@ +langcode: en +status: true +dependencies: + config: + - content_moderation.moderation_state.published +id: published_published +label: 'Publish' +stateFrom: published +stateTo: published +weight: -4 diff --git a/core/modules/content_moderation/config/schema/content_moderation.schema.yml b/core/modules/content_moderation/config/schema/content_moderation.schema.yml new file mode 100644 index 0000000..1265e3e --- /dev/null +++ b/core/modules/content_moderation/config/schema/content_moderation.schema.yml @@ -0,0 +1,50 @@ +content_moderation.moderation_state.*: + type: config_entity + label: 'Moderation state config' + mapping: + id: + type: string + label: 'ID' + label: + type: label + label: 'Label' + published: + type: boolean + label: 'Is published' + default_revision: + type: boolean + label: 'Is default revision' + uuid: + type: string + +node.type.*.third_party.content_moderation: + type: mapping + label: 'Enable moderation states for this node type' + mapping: + enabled: + type: boolean + label: 'Moderation states enabled' + allowed_moderation_states: + type: sequence + sequence: + type: string + label: 'Moderation state' + default_moderation_state: + type: string + label: 'Moderation state for new content' + +block_content.type.*.third_party.content_moderation: + type: mapping + label: 'Enable moderation states for this block content type' + mapping: + enabled: + type: boolean + label: 'Moderation states enabled' + allowed_moderation_states: + type: sequence + sequence: + type: string + label: 'Moderation state' + default_moderation_state: + type: string + label: 'Moderation state for new block content' diff --git a/core/modules/content_moderation/config/schema/content_moderation_transition.schema.yml b/core/modules/content_moderation/config/schema/content_moderation_transition.schema.yml new file mode 100644 index 0000000..0ae5dba --- /dev/null +++ b/core/modules/content_moderation/config/schema/content_moderation_transition.schema.yml @@ -0,0 +1,21 @@ +content_moderation.moderation_state_transition.*: + type: config_entity + label: 'Moderation state transition config' + mapping: + id: + type: string + label: 'ID' + label: + type: label + label: 'Label' + stateFrom: + type: string + label: 'From state' + stateTo: + type: string + label: 'To state' + weight: + type: integer + label: 'Weight' + uuid: + type: string diff --git a/core/modules/content_moderation/content_moderation.info.yml b/core/modules/content_moderation/content_moderation.info.yml new file mode 100644 index 0000000..12f6059 --- /dev/null +++ b/core/modules/content_moderation/content_moderation.info.yml @@ -0,0 +1,10 @@ +name: 'Content Moderation' +type: module +description: 'Provides moderation states for content' +version: VERSION +core: 8.x +package: Core (Experimental) +configure: content_moderation.overview +dependencies: + - views + - options diff --git a/core/modules/content_moderation/content_moderation.install b/core/modules/content_moderation/content_moderation.install new file mode 100644 index 0000000..5af7953 --- /dev/null +++ b/core/modules/content_moderation/content_moderation.install @@ -0,0 +1,40 @@ +selectRevisionableEntities((\Drupal::entityTypeManager()->getDefinitions())); + + // Some modules, such as Entity Pilot, seem to have a weirdness with their + // base field definition such that an entity may end up in this list that + // does not end up selected in EntityTypeInfo::entityTypeAlter(). The result + // is that the moderation_state field is null, and thus trying to install + // a field with a null definition explodes (rightly so). + // Until that oddity is sorted out, we can at least put an extra check in + // here to filter out such broken entities. + // @todo Remove when the underlying bug is fixed. + // @see https://www.drupal.org/node/2674446 + $revisionable_entity_defintions = array_filter($revisionable_entity_defintions, function(\Drupal\Core\Entity\ContentEntityTypeInterface $type) use ($field_manager) { + return !empty($field_manager->getFieldStorageDefinitions($type->id())['moderation_state']); + }); + + /** @var \Drupal\Core\Entity\ContentEntityTypeInterface $type */ + foreach ($revisionable_entity_defintions as $type) { + $content_moderation_definition = $field_manager->getFieldStorageDefinitions($type->id())['moderation_state']; + $entity_definition_update_manager->installFieldStorageDefinition('moderation_state', $type->id(), 'moderation_state', $content_moderation_definition); + } +} diff --git a/core/modules/content_moderation/content_moderation.libraries.yml b/core/modules/content_moderation/content_moderation.libraries.yml new file mode 100644 index 0000000..31dd1c4 --- /dev/null +++ b/core/modules/content_moderation/content_moderation.libraries.yml @@ -0,0 +1,5 @@ +entity-moderation-form: + version: 1.x + css: + layout: + css/entity-moderation-form.css: {} diff --git a/core/modules/content_moderation/content_moderation.links.action.yml b/core/modules/content_moderation/content_moderation.links.action.yml new file mode 100644 index 0000000..9de5061 --- /dev/null +++ b/core/modules/content_moderation/content_moderation.links.action.yml @@ -0,0 +1,11 @@ +entity.moderation_state.add_form: + route_name: 'entity.moderation_state.add_form' + title: 'Add Moderation state' + appears_on: + - entity.moderation_state.collection + +entity.moderation_state_transition.add_form: + route_name: 'entity.moderation_state_transition.add_form' + title: 'Add Moderation state transition' + appears_on: + - entity.moderation_state_transition.collection diff --git a/core/modules/content_moderation/content_moderation.links.menu.yml b/core/modules/content_moderation/content_moderation.links.menu.yml new file mode 100644 index 0000000..0fcb3eb --- /dev/null +++ b/core/modules/content_moderation/content_moderation.links.menu.yml @@ -0,0 +1,21 @@ +# Moderation state menu items definition +content_moderation.overview: + title: 'Content moderation' + route_name: content_moderation.overview + description: 'Configure states and transitions for entities.' + parent: system.admin_config_workflow + +entity.moderation_state.collection: + title: 'Moderation states' + route_name: entity.moderation_state.collection + description: 'Administer moderation states.' + parent: content_moderation.overview + weight: 10 + +# Moderation state transition menu items definition +entity.moderation_state_transition.collection: + title: 'Moderation state transitions' + route_name: entity.moderation_state_transition.collection + description: 'Administer moderation states transitions.' + parent: content_moderation.overview + weight: 20 diff --git a/core/modules/content_moderation/content_moderation.links.task.yml b/core/modules/content_moderation/content_moderation.links.task.yml new file mode 100644 index 0000000..d715219 --- /dev/null +++ b/core/modules/content_moderation/content_moderation.links.task.yml @@ -0,0 +1,3 @@ +moderation_state.entities: + deriver: 'Drupal\content_moderation\Plugin\Derivative\DynamicLocalTasks' + weight: 100 diff --git a/core/modules/content_moderation/content_moderation.module b/core/modules/content_moderation/content_moderation.module new file mode 100644 index 0000000..2d04b37 --- /dev/null +++ b/core/modules/content_moderation/content_moderation.module @@ -0,0 +1,238 @@ + draft without new + * revision) - i.e. unpublish + */ + +use Drupal\Core\Access\AccessResult; +use Drupal\Core\Entity\Display\EntityViewDisplayInterface; +use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Entity\EntityTypeInterface; +use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Routing\RouteMatchInterface; +use Drupal\Core\Session\AccountInterface; +use Drupal\node\NodeInterface; +use Drupal\node\Plugin\Action\PublishNode; +use Drupal\node\Plugin\Action\UnpublishNode; +use Drupal\content_moderation\Plugin\Action\ModerationOptOutPublishNode; +use Drupal\content_moderation\Plugin\Action\ModerationOptOutUnpublishNode; +use Drupal\content_moderation\Plugin\Menu\EditTab; + +/** + * Implements hook_help(). + */ +function content_moderation_help($route_name, RouteMatchInterface $route_match) { + switch ($route_name) { + // Main module help for the content_moderation module. + case 'help.page.content_moderation': + $output = ''; + $output .= '

' . t('About') . '

'; + $output .= '

' . t('The Content Moderation module provides basic moderation for content. For more information, see the online documentation for the Content Moderation module.', array(':content_moderation' => 'https://www.drupal.org/documentation/modules/workbench_moderation/')) . '

'; + return $output; + + default: + } +} + +/** + * Implements hook_entity_base_field_info(). + */ +function content_moderation_entity_base_field_info(EntityTypeInterface $entity_type) { + return \Drupal::service('content_moderation.entity_type')->entityBaseFieldInfo($entity_type); +} + +/** + * Implements hook_module_implements_alter(). + */ +function content_moderation_module_implements_alter(&$implementations, $hook) { + /** @var \Drupal\content_moderation\InlineEditingDisabler $inline_editing_disabler */ + $inline_editing_disabler = \Drupal::service('content_moderation.inline_editing_disabler'); + $inline_editing_disabler->moduleImplementsAlter($implementations, $hook); +} + +/** + * Implements hook_entity_view_alter(). + */ +function content_moderation_entity_view_alter(&$build, EntityInterface $entity, EntityViewDisplayInterface $display) { + /** @var \Drupal\content_moderation\InlineEditingDisabler $inline_editing_disabler */ + $inline_editing_disabler = \Drupal::service('content_moderation.inline_editing_disabler'); + $inline_editing_disabler->entityViewAlter($build, $entity, $display); +} + +/** + * Implements hook_entity_type_alter(). + */ +function content_moderation_entity_type_alter(array &$entity_types) { + \Drupal::service('content_moderation.entity_type')->entityTypeAlter($entity_types); +} + +/** + * Implements hook_entity_operation(). + */ +function content_moderation_entity_operation(EntityInterface $entity) { + return \Drupal::service('content_moderation.entity_type')->entityOperation($entity); +} + +/** + * Sets required flag based on enabled state. + */ +function content_moderation_entity_bundle_field_info_alter(&$fields, EntityTypeInterface $entity_type, $bundle) { + return \Drupal::service('content_moderation.entity_type')->entityBundleFieldInfoAlter($fields, $entity_type, $bundle); +} + +/** + * Implements hook_entity_storage_load(). + */ +function content_moderation_entity_storage_load(array $entities, $entity_type_id) { + // Work around the fact that this hook might be called when the container is + // not fully initialized after the module has been enabled. + // @todo Remove this check after https://www.drupal.org/node/2753733 is fixed. + if (\Drupal::hasService('content_moderation.entity_operations')) { + \Drupal::service('content_moderation.entity_operations')->entityStorageLoad($entities, $entity_type_id); + } +} + +/** + * Implements hook_entity_presave(). + */ +function content_moderation_entity_presave(EntityInterface $entity) { + return \Drupal::service('content_moderation.entity_operations')->entityPresave($entity); +} + +/** + * Implements hook_entity_insert(). + */ +function content_moderation_entity_insert(EntityInterface $entity) { + return \Drupal::service('content_moderation.entity_operations')->entityInsert($entity); +} + +/** + * Implements hook_entity_update(). + */ +function content_moderation_entity_update(EntityInterface $entity) { + return \Drupal::service('content_moderation.entity_operations')->entityUpdate($entity); +} + +/** + * Implements hook_local_tasks_alter(). + */ +function content_moderation_local_tasks_alter(&$local_tasks) { + $content_entity_type_ids = array_keys(array_filter(\Drupal::entityTypeManager()->getDefinitions(), function (EntityTypeInterface $entity_type) { + return $entity_type->isRevisionable(); + })); + + foreach ($content_entity_type_ids as $content_entity_type_id) { + if (isset($local_tasks["entity.$content_entity_type_id.edit_form"])) { + $local_tasks["entity.$content_entity_type_id.edit_form"]['class'] = EditTab::class; + $local_tasks["entity.$content_entity_type_id.edit_form"]['entity_type_id'] = $content_entity_type_id; + } + } +} + +/** + * Implements hook_form_alter(). + */ +function content_moderation_form_alter(&$form, FormStateInterface $form_state, $form_id) { + return \Drupal::service('content_moderation.entity_type')->bundleFormAlter($form, $form_state, $form_id); +} + +/** + * Implements hook_preprocess_HOOK(). + * + * Many default node templates rely on $page to determine whether to output the + * node title as part of the node content. + */ +function content_moderation_preprocess_node(&$variables) { + \Drupal::service('content_moderation.content_preprocess')->preprocessNode($variables); +} + +/** + * Implements hook_entity_extra_field_info(). + */ +function content_moderation_entity_extra_field_info() { + return \Drupal::service('content_moderation.entity_type')->entityExtraFieldInfo(); +} + +/** + * Implements hook_entity_view(). + */ +function content_moderation_entity_view(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display, $view_mode) { + \Drupal::service('content_moderation.entity_operations')->entityView($build, $entity, $display, $view_mode); +} + +/** + * Implements hook_node_access(). + * + * Nodes in particular should be viewable if unpublished and the user has + * the appropriate permission. This permission is therefore effectively + * mandatory for any user that wants to moderate things. + */ +function content_moderation_node_access(NodeInterface $entity, $operation, AccountInterface $account) { + /** @var \Drupal\content_moderation\ModerationInformationInterface $modinfo */ + $moderation_info = Drupal::service('content_moderation.moderation_information'); + + if ($operation == 'view') { + return (!$entity->isPublished()) + ? AccessResult::allowedIfHasPermission($account, 'view any unpublished content') + : AccessResult::neutral(); + } + elseif ($operation == 'update' && $moderation_info->isModeratableEntity($entity) && $entity->moderation_information && $entity->moderation_information->target_id) { + /** @var \Drupal\content_moderation\StateTransitionValidation $transition_validation */ + $transition_validation = \Drupal::service('content_moderation.state_transition_validation'); + + return $transition_validation->getValidTransitionTargets($entity, $account) + ? AccessResult::neutral() + : AccessResult::forbidden(); + } +} + +/** + * Implements hook_theme(). + */ +function content_moderation_theme() { + return ['entity_moderation_form' => ['render element' => 'form']]; +} + +/** + * Implements hook_action_info_alter(). + */ +function content_moderation_action_info_alter(&$definitions) { + + // The publish/unpublish actions are not valid on moderated entities. So swap + // their implementations out for alternates that will become a no-op on a + // moderated node. If another module has already swapped out those classes, + // though, we'll be polite and do nothing. + if (isset($definitions['node_publish_action']['class']) && $definitions['node_publish_action']['class'] == PublishNode::class) { + $definitions['node_publish_action']['class'] = ModerationOptOutPublishNode::class; + } + if (isset($definitions['node_unpublish_action']['class']) && $definitions['node_unpublish_action']['class'] == UnpublishNode::class) { + $definitions['node_unpublish_action']['class'] = ModerationOptOutUnpublishNode::class; + } +} + +/** + * Implements hook_views_data_alter(). + */ +function content_moderation_views_data_alter(array &$data) { + + /** @var \Drupal\content_moderation\ModerationInformationInterface $mod_info */ + $mod_info = \Drupal::service('content_moderation.moderation_information'); + + /** @var \Drupal\Core\Entity\EntityTypeManagerInterface $etm */ + $etm = \Drupal::service('entity_type.manager'); + + $revisionable_types = $mod_info->selectRevisionableEntities($etm->getDefinitions()); + + foreach ($revisionable_types as $type) { + $data[$type->getRevisionTable()]['latest_revision'] = [ + 'title' => t('Is Latest Revision'), + 'help' => t('Restrict the view to only revisions that are the latest revision of their entity.'), + 'filter' => ['id' => 'latest_revision'], + ]; + } +} diff --git a/core/modules/content_moderation/content_moderation.permissions.yml b/core/modules/content_moderation/content_moderation.permissions.yml new file mode 100644 index 0000000..34cf91a --- /dev/null +++ b/core/modules/content_moderation/content_moderation.permissions.yml @@ -0,0 +1,20 @@ +view any unpublished content: + title: View any unpublished content + description: This permission is necessary for any users that may moderate content. + +'administer moderation states': + title: Administer moderation states + description: Create and edit moderation states. + 'restrict access': TRUE + +'administer moderation state transitions': + title: Administer content moderation state transitions + description: Create and edit content moderation state transitions. + 'restrict access': TRUE + +view latest version: + title: View the latest version + description: View the latest version of an entity. (Also requires "View any unpublished content" permission) + +permission_callbacks: + - \Drupal\content_moderation\Permissions::transitionPermissions diff --git a/core/modules/content_moderation/content_moderation.routing.yml b/core/modules/content_moderation/content_moderation.routing.yml new file mode 100644 index 0000000..1ecc3cc --- /dev/null +++ b/core/modules/content_moderation/content_moderation.routing.yml @@ -0,0 +1,89 @@ +content_moderation.overview: + path: '/admin/structure/content-moderation' + defaults: + _controller: '\Drupal\system\Controller\SystemController::systemAdminMenuBlockPage' + _title: 'Content moderation' + requirements: + _permission: 'access administration pages' + +# ModerationState routing definition +entity.moderation_state.collection: + path: '/admin/structure/content-moderation/states' + defaults: + _entity_list: 'moderation_state' + _title: 'Moderation states' + requirements: + _permission: 'administer moderation states' + options: + _admin_route: TRUE + +entity.moderation_state.add_form: + path: '/admin/structure/content-moderation/states/add' + defaults: + _entity_form: 'moderation_state.add' + _title: 'Add Moderation state' + requirements: + _permission: 'administer moderation states' + options: + _admin_route: TRUE + +entity.moderation_state.edit_form: + path: '/admin/structure/content-moderation/states/{moderation_state}' + defaults: + _entity_form: 'moderation_state.edit' + _title: 'Edit Moderation state' + requirements: + _permission: 'administer moderation states' + options: + _admin_route: TRUE + +entity.moderation_state.delete_form: + path: '/admin/structure/content-moderation/states/{moderation_state}/delete' + defaults: + _entity_form: 'moderation_state.delete' + _title: 'Delete Moderation state' + requirements: + _permission: 'administer moderation states' + options: + _admin_route: TRUE + +# ModerationStateTransition routing definition +entity.moderation_state_transition.collection: + path: '/admin/structure/content-moderation/transitions' + defaults: + _entity_list: 'moderation_state_transition' + _title: 'Moderation state transitions' + requirements: + _permission: 'administer moderation state transitions' + options: + _admin_route: TRUE + +entity.moderation_state_transition.add_form: + path: '/admin/structure/content-moderation/transitions/add' + defaults: + _entity_form: 'moderation_state_transition.add' + _title: 'Add Moderation state transition' + requirements: + _permission: 'administer moderation state transitions' + options: + _admin_route: TRUE + +entity.moderation_state_transition.edit_form: + path: '/admin/structure/content-moderation/transitions/{moderation_state_transition}' + defaults: + _entity_form: 'moderation_state_transition.edit' + _title: 'Edit Moderation state transition' + requirements: + _permission: 'administer moderation state transitions' + options: + _admin_route: TRUE + +entity.moderation_state_transition.delete_form: + path: '/admin/structure/content-moderation/transitions/{moderation_state_transition}/delete' + defaults: + _entity_form: 'moderation_state_transition.delete' + _title: 'Delete Moderation state transition' + requirements: + _permission: 'administer moderation state transitions' + options: + _admin_route: TRUE diff --git a/core/modules/content_moderation/content_moderation.services.yml b/core/modules/content_moderation/content_moderation.services.yml new file mode 100644 index 0000000..0c3c872 --- /dev/null +++ b/core/modules/content_moderation/content_moderation.services.yml @@ -0,0 +1,35 @@ +services: + paramconverter.latest_revision: + class: Drupal\content_moderation\ParamConverter\EntityRevisionConverter + arguments: ['@entity.manager', '@content_moderation.moderation_information'] + tags: + - { name: paramconverter, priority: 5 } + arguments: ['@entity.manager'] + content_moderation.state_transition_validation: + class: \Drupal\content_moderation\StateTransitionValidation + arguments: ['@entity_type.manager', '@entity.query'] + content_moderation.moderation_information: + class: Drupal\content_moderation\ModerationInformation + arguments: ['@entity_type.manager', '@current_user'] + content_moderation.entity_type: + class: Drupal\content_moderation\EntityTypeInfo + arguments: ['@string_translation', '@content_moderation.moderation_information', '@entity_type.manager'] + content_moderation.entity_operations: + class: Drupal\content_moderation\EntityOperations + arguments: ['@content_moderation.moderation_information', '@entity_type.manager', '@form_builder', '@event_dispatcher', '@content_moderation.revision_tracker'] + content_moderation.inline_editing_disabler: + class: \Drupal\content_moderation\InlineEditingDisabler + arguments: ['@content_moderation.moderation_information'] + content_moderation.content_preprocess: + class: Drupal\content_moderation\ContentPreprocess + arguments: ['@current_route_match'] + access_check.latest_revision: + class: Drupal\content_moderation\Access\LatestRevisionCheck + arguments: ['@content_moderation.moderation_information'] + tags: + - { name: access_check, applies_to: _content_moderation_latest_version } + content_moderation.revision_tracker: + class: Drupal\content_moderation\RevisionTracker + arguments: ['@database'] + tags: + - { name: backend_overridable } diff --git a/core/modules/content_moderation/css/entity-moderation-form.css b/core/modules/content_moderation/css/entity-moderation-form.css new file mode 100644 index 0000000..ec09407 --- /dev/null +++ b/core/modules/content_moderation/css/entity-moderation-form.css @@ -0,0 +1,16 @@ +ul.entity-moderation-form { + list-style: none; + display: -webkit-flex; /* Safari */ + display: flex; + -webkit-flex-wrap: wrap; /* Safari */ + flex-wrap: wrap; + -webkit-justify-content: space-around; /* Safari */ + justify-content: space-around; + -webkit-align-items: flex-end; /* Safari */ + align-items: flex-end; + border-bottom: 1px solid gray; +} + +ul.entity-moderation-form input[type=submit] { + margin-bottom: 1.2em; +} diff --git a/core/modules/content_moderation/src/Access/LatestRevisionCheck.php b/core/modules/content_moderation/src/Access/LatestRevisionCheck.php new file mode 100644 index 0000000..8632c4e --- /dev/null +++ b/core/modules/content_moderation/src/Access/LatestRevisionCheck.php @@ -0,0 +1,82 @@ +moderationInfo = $moderation_information; + } + + /** + * Checks that there is a forward revision available. + * + * This checker assumes the presence of an '_entity_access' requirement key + * in the same form as used by EntityAccessCheck. + * + * @see EntityAccessCheck. + * + * @param \Symfony\Component\Routing\Route $route + * The route to check against. + * @param \Drupal\Core\Routing\RouteMatchInterface $route_match + * The parametrized route + * + * @return \Drupal\Core\Access\AccessResultInterface + * The access result. + */ + public function access(Route $route, RouteMatchInterface $route_match) { + + // This tab should not show up period unless there's a reason to show it. + // @todo Do we need any extra cache tags here? + $entity = $this->loadEntity($route, $route_match); + return $this->moderationInfo->hasForwardRevision($entity) + ? AccessResult::allowed()->addCacheableDependency($entity) + : AccessResult::forbidden()->addCacheableDependency($entity); + } + + /** + * Returns the default revision of the entity this route is for. + * + * @param \Symfony\Component\Routing\Route $route + * The route to check against. + * @param \Drupal\Core\Routing\RouteMatchInterface $route_match + * The parametrized route + * + * @return ContentEntityInterface + * returns the Entity in question. + * + * @throws \Exception + * A generic exception is thrown if the entity couldn't be loaded. This + * almost always implies a developer error, so it should get turned into + * an HTTP 500. + */ + protected function loadEntity(Route $route, RouteMatchInterface $route_match) { + $entity_type = $route->getOption('_content_moderation_entity_type'); + + if ($entity = $route_match->getParameter($entity_type)) { + if ($entity instanceof EntityInterface) { + return $entity; + } + } + throw new \Exception(sprintf('%s is not a valid entity route. The LatestRevisionCheck access checker may only be used with a route that has a single entity parameter.', $route_match->getRouteName())); + } +} diff --git a/core/modules/content_moderation/src/ContentPreprocess.php b/core/modules/content_moderation/src/ContentPreprocess.php new file mode 100644 index 0000000..ec53d2a --- /dev/null +++ b/core/modules/content_moderation/src/ContentPreprocess.php @@ -0,0 +1,55 @@ +routeMatch = $route_match; + } + + /** + * Wrapper for hook_preprocess_HOOK(). + * + * @param array $variables + * Theme variables to preprocess. + */ + public function preprocessNode(array &$variables) { + // Set the 'page' template variable when the node is being displayed on the + // "Latest version" tab provided by content_moderation. + $variables['page'] = $variables['page'] || $this->isLatestVersionPage($variables['node']); + } + + /** + * Checks whether a route is the "Latest version" tab of a node. + * + * @param \Drupal\node\Entity\Node $node + * A node. + * + * @return bool + * True if the current route is the latest version tab of the given node. + */ + public function isLatestVersionPage(Node $node) { + return $this->routeMatch->getRouteName() == 'entity.node.latest_version' + && ($pageNode = $this->routeMatch->getParameter('node')) + && $pageNode->id() == $node->id(); + } + +} diff --git a/core/modules/content_moderation/src/Entity/Handler/BlockContentModerationHandler.php b/core/modules/content_moderation/src/Entity/Handler/BlockContentModerationHandler.php new file mode 100644 index 0000000..b698fac --- /dev/null +++ b/core/modules/content_moderation/src/Entity/Handler/BlockContentModerationHandler.php @@ -0,0 +1,35 @@ +t('Revisions must be required when moderation is enabled.'); + } + + /** + * {@inheritdoc} + */ + public function enforceRevisionsBundleFormAlter(array &$form, FormStateInterface $form_state, $form_id) { + $form['revision']['#default_value'] = 1; + $form['revision']['#disabled'] = TRUE; + $form['revision']['#description'] = $this->t('Revisions must be required when moderation is enabled.'); + } + +} diff --git a/core/modules/content_moderation/src/Entity/Handler/ModerationHandler.php b/core/modules/content_moderation/src/Entity/Handler/ModerationHandler.php new file mode 100644 index 0000000..cb80b49 --- /dev/null +++ b/core/modules/content_moderation/src/Entity/Handler/ModerationHandler.php @@ -0,0 +1,88 @@ +setNewRevision(TRUE); + $entity->isDefaultRevision($default_revision); + } + + /** + * {@inheritdoc} + */ + public function onBundleModerationConfigurationFormSubmit(ConfigEntityInterface $bundle) { + // The Revisions portion of Entity API is not uniformly applied or consistent. + // Until that's fixed in core, we'll make a best-attempt to apply it to + // the common entity patterns so as to avoid every entity type needing to + // implement this method, although some will still need to do so for now. + + // This is the API that should be universal, but isn't yet. See NodeType + // for an example. + if (method_exists($bundle, 'setNewRevision')) { + $bundle->setNewRevision(TRUE); + } + // This is the raw property used by NodeType, and likely others. + elseif ($bundle->get('new_revision') !== NULL) { + $bundle->set('new_revision', TRUE); + } + // This is the raw property used by BlockContentType, and maybe others. + elseif ($bundle->get('revision') !== NULL) { + $bundle->set('revision', TRUE); + } + + + $bundle->save(); + + return; + } + + /** + * {@inheritdoc} + */ + public function enforceRevisionsEntityFormAlter(array &$form, FormStateInterface $form_state, $form_id) { + return; + } + + + /** + * {@inheritdoc} + */ + public function enforceRevisionsBundleFormAlter(array &$form, FormStateInterface $form_state, $form_id) { + return; + } + +} diff --git a/core/modules/content_moderation/src/Entity/Handler/ModerationHandlerInterface.php b/core/modules/content_moderation/src/Entity/Handler/ModerationHandlerInterface.php new file mode 100644 index 0000000..d5295dc --- /dev/null +++ b/core/modules/content_moderation/src/Entity/Handler/ModerationHandlerInterface.php @@ -0,0 +1,77 @@ +shouldModerate($entity)) { + parent::onPresave($entity, $default_revision, $published_state); + // Only nodes have a concept of published. + /** @var $entity Node */ + $entity->setPublished($published_state); + } + } + + /** + * {@inheritdoc} + */ + public function enforceRevisionsEntityFormAlter(array &$form, FormStateInterface $form_state, $form_id) { + $form['revision']['#disabled'] = TRUE; + $form['revision']['#default_value'] = TRUE; + $form['revision']['#description'] = $this->t('Revisions are required.'); + } + + /** + * {@inheritdoc} + */ + public function enforceRevisionsBundleFormAlter(array &$form, FormStateInterface $form_state, $form_id) { + /* @var \Drupal\node\Entity\NodeType $entity */ + $entity = $form_state->getFormObject()->getEntity(); + + if ($entity->getThirdPartySetting('content_moderation', 'enabled', FALSE)) { + // Force the revision checkbox on. + $form['workflow']['options']['#default_value']['revision'] = 'revision'; + $form['workflow']['options']['revision']['#disabled'] = TRUE; + } + } + + /** + * Check if an entity's default revision and/or state needs adjusting. + * + * @param \Drupal\Core\Entity\ContentEntityInterface $entity + * + * @return bool + * TRUE when either the default revision or the state needs to be updated. + */ + protected function shouldModerate(ContentEntityInterface $entity) { + // First condition is needed so you can add a translation. + // Second condition is needed when you want to publish a translation. + // Third condition is needed when you want to create a new draft for a published translation. + return $entity->isDefaultTranslation() || $entity->moderation_state->entity->isPublishedState() || $entity->isPublished(); + } + +} diff --git a/core/modules/content_moderation/src/Entity/ModerationState.php b/core/modules/content_moderation/src/Entity/ModerationState.php new file mode 100644 index 0000000..c19d23a --- /dev/null +++ b/core/modules/content_moderation/src/Entity/ModerationState.php @@ -0,0 +1,81 @@ +published; + } + + /** + * {@inheritdoc} + */ + public function isDefaultRevisionState() { + return $this->published || $this->default_revision; + } + +} diff --git a/core/modules/content_moderation/src/Entity/ModerationStateTransition.php b/core/modules/content_moderation/src/Entity/ModerationStateTransition.php new file mode 100644 index 0000000..4d355e6 --- /dev/null +++ b/core/modules/content_moderation/src/Entity/ModerationStateTransition.php @@ -0,0 +1,144 @@ +getModerationStateConfigPrefix() . '.'; + if ($this->stateFrom) { + $this->addDependency('config', $prefix . $this->stateFrom); + } + if ($this->stateTo) { + $this->addDependency('config', $prefix . $this->stateTo); + } + return $this; + } + + /** + * {@inheritdoc} + */ + public function getFromState() { + return $this->stateFrom; + } + + /** + * {@inheritdoc} + */ + public function getToState() { + return $this->stateTo; + } + + /** + * {@inheritdoc} + */ + public function getWeight() { + return $this->weight; + } + + /** + * Gets the moderation state config prefix. + * + * @return string + * Moderation state config prefix. + */ + protected function getModerationStateConfigPrefix() { + if (!isset($this->moderationStateConfigPrefix)) { + $this->moderationStateConfigPrefix = \Drupal::service('entity_type.manager')->getDefinition('moderation_state')->getConfigPrefix(); + } + return $this->moderationStateConfigPrefix; + } + + /** + * Sets the moderation state config prefix. + * + * @param string $moderation_state_config_prefix + * Moderation state config prefix. + * + * @return self + * Called instance. + */ + public function setModerationStateConfigPrefix($moderation_state_config_prefix) { + $this->moderationStateConfigPrefix = $moderation_state_config_prefix; + return $this; + } + +} diff --git a/core/modules/content_moderation/src/EntityOperations.php b/core/modules/content_moderation/src/EntityOperations.php new file mode 100644 index 0000000..0b35e2d --- /dev/null +++ b/core/modules/content_moderation/src/EntityOperations.php @@ -0,0 +1,267 @@ +moderationInfo = $moderation_info; + $this->entityTypeManager = $entity_type_manager; + $this->eventDispatcher = $event_dispatcher; + $this->formBuilder = $form_builder; + $this->tracker = $tracker; + } + + /** + * Hook bridge. + * + * @see hook_entity_storage_load(). + * + * @param EntityInterface[] $entities + * An array of entity objects that have just been loaded. + * @param string $entity_type_id + * The type of entity being loaded, such as "node" or "user". + */ + public function entityStorageLoad(array $entities, $entity_type_id) { + + // Ensure that all moderatable entities always have a moderation_state field + // with data, in all translations. That avoids us needing to have a thousand + // NULL checks elsewhere in the code. + + // Quickly exclude any non-moderatable entities. + $to_check = array_filter($entities, [$this->moderationInfo, 'isModeratableEntity']); + if (!$to_check) { + return; + } + + // @todo make this more functional, less iterative. + foreach ($to_check as $entity) { + foreach ($entity->getTranslationLanguages() as $language) { + $translation = $entity->getTranslation($language->getId()); + if ($translation->moderation_state->target_id == NULL) { + $translation->moderation_state->target_id = $this->getDefaultLoadStateId($translation); + } + } + } + } + + /** + * Determines the default moderation state on load for an entity. + * + * This method is only applicable when an entity is loaded that has + * no moderation state on it, but should. In those cases, failing to set + * one may result in NULL references elsewhere when other code tries to check + * the moderation state of the entity. + * + * The amount of indirection here makes performance a concern, but + * given how Entity API works I don't know how else to do it. + * This reliably gets us *A* valid state. However, that state may be + * not the ideal one. Suggestions on how to better select the default + * state here are welcome. + * + * @param \Drupal\Core\Entity\ContentEntityInterface $entity + * The entity for which we want a default state. + * + * @return string + * The default state for the given entity. + */ + protected function getDefaultLoadStateId(ContentEntityInterface $entity) { + return $this->moderationInfo + ->loadBundleEntity($entity->getEntityType()->getBundleEntityType(), $entity->bundle()) + ->getThirdPartySetting('content_moderation', 'default_moderation_state'); + } + + /** + * Acts on an entity and set published status based on the moderation state. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity being saved. + */ + public function entityPresave(EntityInterface $entity) { + if (!$this->moderationInfo->isModeratableEntity($entity)) { + return; + } + if ($entity->moderation_state->entity) { + $published_state = $entity->moderation_state->entity->isPublishedState(); + + // This entity is default if it is new, the default revision, or the + // default revision is not published. + $update_default_revision = $entity->isNew() + || $entity->moderation_state->entity->isDefaultRevisionState() + || !$this->isDefaultRevisionPublished($entity); + + // Fire per-entity-type logic for handling the save process. + $this->entityTypeManager->getHandler($entity->getEntityTypeId(), 'moderation')->onPresave($entity, $update_default_revision, $published_state); + + // There's currently a bug in core where $entity->original always points + // to the default revision, for now work around this by loading the latest + // revision. + $latest_revision = $this->moderationInfo->getLatestRevision($entity->getEntityTypeId(), $entity->id()); + $state_before = !empty($latest_revision) ? $latest_revision->moderation_state->target_id : NULL; + // @todo: Revert to this simpler version when https://www.drupal.org/node/2700747 is fixed. + // $state_before = isset($entity->original) ? $entity->original->moderation_state->target_id : NULL; + + $state_after = $entity->moderation_state->target_id; + + // Allow other modules to respond to the transition. Note that this + // does not provide any mechanism to cancel the transition, since + // Entity API doesn't allow hook_entity_presave to short-circuit a save. + $event = new ContentModerationTransitionEvent($entity, $state_before, $state_after); + + $this->eventDispatcher->dispatch(ContentModerationEvents::STATE_TRANSITION, $event); + } + } + + /** + * Hook bridge. + * + * @see hook_entity_insert(). + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity that was just saved. + */ + public function entityInsert(EntityInterface $entity) { + if (!$this->moderationInfo->isModeratableEntity($entity)) { + return; + } + + /** ContentEntityInterface $entity */ + + // Update our own record keeping. + $this->tracker->setLatestRevision($entity->getEntityTypeId(), $entity->id(), $entity->language()->getId(), $entity->getRevisionId()); + } + + /** + * Hook bridge. + * + * @see hook_entity_update(). + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity that was just saved. + */ + public function entityUpdate(EntityInterface $entity) { + if (!$this->moderationInfo->isModeratableEntity($entity)) { + return; + } + + /** ContentEntityInterface $entity */ + + // Update our own record keeping. + $this->tracker->setLatestRevision($entity->getEntityTypeId(), $entity->id(), $entity->language()->getId(), $entity->getRevisionId()); + } + + /** + * Act on entities being assembled before rendering. + * + * This is a hook bridge. + * + * @see hook_entity_view() + * @see EntityFieldManagerInterface::getExtraFields() + */ + public function entityView(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display, $view_mode) { + + if (!$this->moderationInfo->isModeratableEntity($entity)) { + return; + } + if (!$this->moderationInfo->isLatestRevision($entity)) { + return; + } + /** @var ContentEntityInterface $entity */ + if ($entity->isDefaultRevision()) { + return; + } + + $component = $display->getComponent('content_moderation_control'); + if ($component) { + $build['content_moderation_control'] = $this->formBuilder->getForm(EntityModerationForm::class, $entity); + $build['content_moderation_control']['#weight'] = $component['weight']; + } + } + + /** + * Check if the default revision for the given entity is published. + * + * The default revision is the same as the entity retrieved by "default" from + * the storage handler. If the entity is translated, use the default revision + * of the same language as the given entity. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity being saved. + * + * @return bool + * TRUE if the default revision is published. FALSE otherwise. + */ + protected function isDefaultRevisionPublished(EntityInterface $entity) { + $storage = $this->entityTypeManager->getStorage($entity->getEntityTypeId()); + $default_revision = $storage->load($entity->id()); + + // Ensure we are comparing the same translation as the current entity. + if ($default_revision instanceof TranslatableInterface && $default_revision->isTranslatable()) { + // If there is no translation, then there is no default revision and is + // therefore not published. + if (!$default_revision->hasTranslation($entity->language()->getId())) { + return FALSE; + } + + $default_revision = $default_revision->getTranslation($entity->language()->getId()); + } + + return $default_revision && $default_revision->moderation_state->entity->isPublishedState(); + } + +} diff --git a/core/modules/content_moderation/src/EntityTypeInfo.php b/core/modules/content_moderation/src/EntityTypeInfo.php new file mode 100644 index 0000000..c6353e5 --- /dev/null +++ b/core/modules/content_moderation/src/EntityTypeInfo.php @@ -0,0 +1,358 @@ + NodeModerationHandler::class, + 'block_content' => BlockContentModerationHandler::class, + ]; + + /** + * EntityTypeInfo constructor. + * + * @param \Drupal\Core\StringTranslation\TranslationInterface $translation + * The translation service. for form alters. + * @param \Drupal\content_moderation\ModerationInformationInterface $moderation_information + * The moderation information service. + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager + * Entity type manager. + */ + public function __construct(TranslationInterface $translation, ModerationInformationInterface $moderation_information, EntityTypeManagerInterface $entity_type_manager) { + $this->stringTranslation = $translation; + $this->moderationInfo = $moderation_information; + $this->entityTypeManager = $entity_type_manager; + } + + /** + * Adds Moderation configuration to appropriate entity types. + * + * This is an alter hook bridge. + * + * @param EntityTypeInterface[] $entity_types + * The master entity type list to alter. + * + * @see hook_entity_type_alter(). + */ + public function entityTypeAlter(array &$entity_types) { + foreach ($this->moderationInfo->selectRevisionableEntityTypes($entity_types) as $type_name => $type) { + $entity_types[$type_name] = $this->addModerationToEntityType($type); + $entity_types[$type->get('bundle_of')] = $this->addModerationToEntity($entity_types[$type->get('bundle_of')]); + } + } + + /** + * Modifies an entity definition to include moderation support. + * + * This primarily just means an extra handler. A Generic one is provided, + * but individual entity types can provide their own as appropriate. + * + * @param \Drupal\Core\Entity\ContentEntityTypeInterface $type + * The content entity definition to modify. + * + * @return \Drupal\Core\Entity\ContentEntityTypeInterface + * The modified content entity definition. + */ + protected function addModerationToEntity(ContentEntityTypeInterface $type) { + if (!$type->hasHandlerClass('moderation')) { + $handler_class = !empty($this->moderationHandlers[$type->id()]) ? $this->moderationHandlers[$type->id()] : ModerationHandler::class; + $type->setHandlerClass('moderation', $handler_class); + } + + if (!$type->hasLinkTemplate('latest-version') && $type->hasLinkTemplate('canonical')) { + $type->setLinkTemplate('latest-version', $type->getLinkTemplate('canonical') . '/latest'); + } + + // @todo Core forgot to add a direct way to manipulate route_provider, so + // we have to do it the sloppy way for now. + $providers = $type->getRouteProviderClasses() ?: []; + if (empty($providers['moderation'])) { + $providers['moderation'] = EntityModerationRouteProvider::class; + $type->setHandlerClass('route_provider', $providers); + } + + return $type; + } + + /** + * Modifies an entity type definition to include moderation configuration support. + * + * That "configuration support" includes a configuration form, a hypermedia + * link, and a route provider to tie it all together. There's also a + * moderation handler for per-entity-type variation. + * + * @param \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $type + * The config entity definition to modify. + * + * @return \Drupal\Core\Config\Entity\ConfigEntityTypeInterface + * The modified config entity definition. + */ + protected function addModerationToEntityType(ConfigEntityTypeInterface $type) { + if ($type->hasLinkTemplate('edit-form') && !$type->hasLinkTemplate('moderation-form')) { + $type->setLinkTemplate('moderation-form', $type->getLinkTemplate('edit-form') . '/moderation'); + } + + if (!$type->getFormClass('moderation')) { + $type->setFormClass('moderation', BundleModerationConfigurationForm::class); + } + + // @todo Core forgot to add a direct way to manipulate route_provider, so + // we have to do it the sloppy way for now. + $providers = $type->getRouteProviderClasses() ?: []; + if (empty($providers['moderation'])) { + $providers['moderation'] = EntityTypeModerationRouteProvider::class; + $type->setHandlerClass('route_provider', $providers); + } + + return $type; + } + + /** + * Adds an operation on bundles that should have a Moderation form. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity on which to define an operation. + * + * @return array + * An array of operation definitions. + * + * @see hook_entity_operation(). + */ + public function entityOperation(EntityInterface $entity) { + $operations = []; + $type = $entity->getEntityType(); + + if ($this->moderationInfo->isBundleForModeratableEntity($entity)) { + $operations['manage-moderation'] = [ + 'title' => t('Manage moderation'), + 'weight' => 27, + 'url' => Url::fromRoute("entity.{$type->id()}.moderation", [$entity->getEntityTypeId() => $entity->id()]), + ]; + } + + return $operations; + } + + /** + * Gets the "extra fields" for a bundle. + * + * This is a hook bridge. + * + * @see hook_entity_extra_field_info() + * + * @return array + * A nested array of 'pseudo-field' elements. Each list is nested within the + * following keys: entity type, bundle name, context (either 'form' or + * 'display'). The keys are the name of the elements as appearing in the + * renderable array (either the entity form or the displayed entity). The + * value is an associative array: + * - label: The human readable name of the element. Make sure you sanitize + * this appropriately. + * - description: A short description of the element contents. + * - weight: The default weight of the element. + * - visible: (optional) The default visibility of the element. Defaults to + * TRUE. + * - edit: (optional) String containing markup (normally a link) used as the + * element's 'edit' operation in the administration interface. Only for + * 'form' context. + * - delete: (optional) String containing markup (normally a link) used as the + * element's 'delete' operation in the administration interface. Only for + * 'form' context. + */ + public function entityExtraFieldInfo() { + $return = []; + foreach ($this->getModeratedBundles() as $bundle) { + $return[$bundle['entity']][$bundle['bundle']]['display']['content_moderation_control'] = [ + 'label' => $this->t('Moderation control'), + 'description' => $this->t('Status listing and form for the entitiy\'s moderation state.'), + 'weight' => -20, + 'visible' => TRUE, + ]; + } + + return $return; + } + + /** + * Returns an iterable list of entity names and bundle names under moderation. + * + * That is, this method returns a list of bundles that have Content + * Moderation enabled on them. + * + * @return \Generator + * A generator, yielding a 2 element associative array: + * - entity: The machine name of an entity, such as "node" or "block_content". + * - bundle: The machine name of a bundle, such as "page" or "article". + */ + protected function getModeratedBundles() { + $revisionable_types = $this->moderationInfo->selectRevisionableEntityTypes($this->entityTypeManager->getDefinitions()); + /** @var ConfigEntityTypeInterface $type */ + foreach ($revisionable_types as $type_name => $type) { + $result = $this->entityTypeManager + ->getStorage($type_name) + ->getQuery() + ->condition('third_party_settings.content_moderation.enabled', TRUE) + ->execute(); + + foreach ($result as $bundle_name) { + yield ['entity' => $type->getBundleOf(), 'bundle' => $bundle_name]; + } + } + } + + /** + * Adds base field info to an entity type. + * + * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type + * Entity type for adding base fields to. + * + * @return \Drupal\Core\Field\BaseFieldDefinition[] + * New fields added by moderation state. + */ + public function entityBaseFieldInfo(EntityTypeInterface $entity_type) { + + if (!$this->moderationInfo->isModeratableEntityType($entity_type)) { + return []; + } + + $fields = []; + // @todo write a test for this. + $fields['moderation_state'] = BaseFieldDefinition::create('entity_reference') + ->setLabel(t('Moderation state')) + ->setDescription(t('The moderation state of this piece of content.')) + ->setSetting('target_type', 'moderation_state') + ->setTargetEntityTypeId($entity_type->id()) + ->setRevisionable(TRUE) + ->setTranslatable(TRUE) + // @todo write a test for this. + ->setDisplayOptions('view', [ + 'label' => 'hidden', + 'type' => 'hidden', + 'weight' => -5, + ]) + // @todo write a custom widget/selection handler plugin instead of + // manual filtering? + ->setDisplayOptions('form', [ + 'type' => 'moderation_state_default', + 'weight' => 5, + 'settings' => [], + ]) + ->addConstraint('ModerationState', []) + ->setDisplayConfigurable('form', FALSE) + ->setDisplayConfigurable('view', FALSE); + return $fields; + } + + /** + * Force moderatable bundles to have a moderation_state field. + * + * @param \Drupal\Core\Field\FieldDefinitionInterface[] $fields + * The array of bundle field definitions. + * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type + * The entity type definition. + * @param string $bundle + * The bundle. + * + * @see hook_entity_bundle_field_info_alter(); + */ + public function entityBundleFieldInfoAlter(&$fields, EntityTypeInterface $entity_type, $bundle) { + if ($this->moderationInfo->isModeratableBundle($entity_type, $bundle) && !empty($fields['moderation_state'])) { + $fields['moderation_state']->addConstraint('ModerationState', []); + } + } + + /** + * Alters bundle forms to enforce revision handling. + * + * @param array $form + * An associative array containing the structure of the form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * @param string $form_id + * The form id. + * + * @see hook_form_alter() + */ + public function bundleFormAlter(array &$form, FormStateInterface $form_state, $form_id) { + if ($this->moderationInfo->isRevisionableBundleForm($form_state->getFormObject())) { + /* @var ConfigEntityTypeInterface $bundle */ + $bundle = $form_state->getFormObject()->getEntity(); + + $this->entityTypeManager->getHandler($bundle->getEntityType()->getBundleOf(), 'moderation')->enforceRevisionsBundleFormAlter($form, $form_state, $form_id); + } + else if ($this->moderationInfo->isModeratedEntityForm($form_state->getFormObject())) { + /* @var ContentEntityInterface $entity */ + $entity = $form_state->getFormObject()->getEntity(); + + $this->entityTypeManager->getHandler($entity->getEntityTypeId(), 'moderation')->enforceRevisionsEntityFormAlter($form, $form_state, $form_id); + + // Submit handler to redirect to the + $form['actions']['submit']['#submit'][] = '\Drupal\content_moderation\EntityTypeInfo::bundleFormRedirect'; + } + } + + /** + * Redirect content entity edit forms on save, if there is a forward revision. + * + * When saving their changes, editors should see those changes displayed on + * the next page. + * + * @param array $form + * @param \Drupal\Core\Form\FormStateInterface $form_state + */ + public static function bundleFormRedirect(array &$form, FormStateInterface $form_state) { + /* @var ContentEntityInterface $entity */ + $entity = $form_state->getFormObject()->getEntity(); + + $moderation_info = \Drupal::getContainer()->get('content_moderation.moderation_information'); + if ($moderation_info->hasForwardRevision($entity) && $entity->hasLinkTemplate('latest-version')) { + $entity_type_id = $entity->getEntityTypeId(); + $form_state->setRedirect("entity.$entity_type_id.latest_version", [$entity_type_id => $entity->id()]); + } + } +} diff --git a/core/modules/content_moderation/src/Event/ContentModerationEvents.php b/core/modules/content_moderation/src/Event/ContentModerationEvents.php new file mode 100644 index 0000000..9c8c5bf --- /dev/null +++ b/core/modules/content_moderation/src/Event/ContentModerationEvents.php @@ -0,0 +1,14 @@ +entity = $entity; + $this->stateBefore = $state_before; + $this->stateAfter = $state_after; + } + + /** + * Returns the changed entity. + * + * @return \Drupal\Core\Entity\ContentEntityInterface + */ + public function getEntity() { + return $this->entity; + } + + /** + * @return string + */ + public function getStateBefore() { + return $this->stateBefore; + } + + /** + * @return string + */ + public function getStateAfter() { + return $this->stateAfter; + } + +} diff --git a/core/modules/content_moderation/src/Form/BundleModerationConfigurationForm.php b/core/modules/content_moderation/src/Form/BundleModerationConfigurationForm.php new file mode 100644 index 0000000..ed43914 --- /dev/null +++ b/core/modules/content_moderation/src/Form/BundleModerationConfigurationForm.php @@ -0,0 +1,191 @@ +entityTypeManager = $entity_type_manager; + } + + /** + * @inheritDoc + */ + public static function create(ContainerInterface $container) { + return new static($container->get('entity_type.manager')); + } + + /** + * {@inheritdoc} + * + * We need to blank out the base form ID so that poorly written form alters + * that use the base form ID to target both add and edit forms don't pick + * up our form. This should be fixed in core. + */ + public function getBaseFormId() { + return NULL; + } + + /** + * {@inheritdoc} + */ + public function form(array $form, FormStateInterface $form_state) { + /* @var ConfigEntityTypeInterface $bundle */ + $bundle = $form_state->getFormObject()->getEntity(); + $form['enable_moderation_state'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Enable moderation states.'), + '#description' => $this->t('Content of this type must transition through moderation states in order to be published.'), + '#default_value' => $bundle->getThirdPartySetting('content_moderation', 'enabled', FALSE), + ]; + + // Add a special message when moderation is being disabled. + if ($bundle->getThirdPartySetting('content_moderation', 'enabled', FALSE)) { + $form['enable_moderation_state_note'] = [ + '#type' => 'item', + '#description' => $this->t('After disabling moderation, any existing forward drafts will be accessible via the "Revisions" tab.'), + '#states' => [ + 'visible' => [ + ':input[name=enable_moderation_state]' => ['checked' => FALSE], + ], + ], + ]; + } + + $states = $this->entityTypeManager->getStorage('moderation_state')->loadMultiple(); + $label = function(ModerationState $state) { + return $state->label(); + }; + + $options_published = array_map($label, array_filter($states, function(ModerationState $state) { + return $state->isPublishedState(); + })); + + $options_unpublished = array_map($label, array_filter($states, function(ModerationState $state) { + return !$state->isPublishedState(); + })); + + $form['allowed_moderation_states_unpublished'] = [ + '#type' => 'checkboxes', + '#title' => $this->t('Allowed moderation states (Unpublished)'), + '#description' => $this->t('The allowed unpublished moderation states this content-type can be assigned.'), + '#default_value' => $bundle->getThirdPartySetting('content_moderation', 'allowed_moderation_states', array_keys($options_unpublished)), + '#options' => $options_unpublished, + '#required' => TRUE, + '#states' => [ + 'visible' => [ + ':input[name=enable_moderation_state]' => ['checked' => TRUE], + ], + ], + ]; + + $form['allowed_moderation_states_published'] = [ + '#type' => 'checkboxes', + '#title' => $this->t('Allowed moderation states (Published)'), + '#description' => $this->t('The allowed published moderation states this content-type can be assigned.'), + '#default_value' => $bundle->getThirdPartySetting('content_moderation', 'allowed_moderation_states', array_keys($options_published)), + '#options' => $options_published, + '#required' => TRUE, + '#states' => [ + 'visible' => [ + ':input[name=enable_moderation_state]' => ['checked' => TRUE], + ], + ], + ]; + + // This is screwy, but the key of the array needs to be a user-facing string + // so we have to fully render the translatable string to a real string, or + // else PHP chokes on an object used as an array key. + $options = [ + $this->t('Unpublished')->render() => $options_unpublished, + $this->t('Published')->render() => $options_published, + ]; + + $form['default_moderation_state'] = [ + '#type' => 'select', + '#title' => $this->t('Default moderation state'), + '#options' => $options, + '#description' => $this->t('Select the moderation state for new content'), + '#default_value' => $bundle->getThirdPartySetting('content_moderation', 'default_moderation_state', 'draft'), + '#states' => [ + 'visible' => [ + ':input[name=enable_moderation_state]' => ['checked' => TRUE], + ], + ], + ]; + $form['#entity_builders'][] = [$this, 'formBuilderCallback']; + + return parent::form($form, $form_state); + } + + /** + * Form builder callback. + * + * @todo I don't know why this needs to be separate from the form() method. + * It was in the form_alter version but we should see if we can just fold + * it into the method above. + * + * @param $entity_type + * @param \Drupal\Core\Config\Entity\ConfigEntityInterface $bundle + * @param array $form + * @param \Drupal\Core\Form\FormStateInterface $form_state + */ + public function formBuilderCallback($entity_type, ConfigEntityInterface $bundle, &$form, FormStateInterface $form_state) { + // @todo write a test for this. + $bundle->setThirdPartySetting('content_moderation', 'enabled', $form_state->getValue('enable_moderation_state')); + $bundle->setThirdPartySetting('content_moderation', 'allowed_moderation_states', array_keys(array_filter($form_state->getValue('allowed_moderation_states_published') + $form_state->getValue('allowed_moderation_states_unpublished')))); + $bundle->setThirdPartySetting('content_moderation', 'default_moderation_state', $form_state->getValue('default_moderation_state')); + } + + /** + * {@inheritdoc} + */ + public function validateForm(array &$form, FormStateInterface $form_state) { + if ($form_state->getValue('enable_moderation_state')) { + $allowed = array_keys(array_filter($form_state->getValue('allowed_moderation_states_published') + $form_state->getValue('allowed_moderation_states_unpublished'))); + + if (($default = $form_state->getValue('default_moderation_state')) && !in_array($default, $allowed, TRUE)) { + $form_state->setErrorByName('default_moderation_state', $this->t('The default moderation state must be one of the allowed states.')); + } + } + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + + // If moderation is enabled, revisions MUST be enabled as well. + // Otherwise we can't have forward revisions. + if($form_state->getValue('enable_moderation_state')) { + /* @var ConfigEntityTypeInterface $bundle */ + $bundle = $form_state->getFormObject()->getEntity(); + + $this->entityTypeManager->getHandler($bundle->getEntityType()->getBundleOf(), 'moderation')->onBundleModerationConfigurationFormSubmit($bundle); + } + + parent::submitForm( $form, $form_state); + + drupal_set_message($this->t('Your settings have been saved.')); + } +} diff --git a/core/modules/content_moderation/src/Form/EntityModerationForm.php b/core/modules/content_moderation/src/Form/EntityModerationForm.php new file mode 100644 index 0000000..eb1ab9a --- /dev/null +++ b/core/modules/content_moderation/src/Form/EntityModerationForm.php @@ -0,0 +1,141 @@ +moderationInfo = $moderation_info; + $this->validation = $validation; + $this->entityTypeManager = $entity_type_manager; + } + + /** + * @inheritDoc + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('content_moderation.moderation_information'), + $container->get('content_moderation.state_transition_validation'), + $container->get('entity_type.manager') + ); + } + + /** + * @inheritDoc + */ + public function getFormId() { + return 'content_moderation_entity_moderation_form'; + } + + /** + * @inheritDoc + */ + public function buildForm(array $form, FormStateInterface $form_state, ContentEntityInterface $entity = NULL) { + /** @var ModerationState $current_state */ + $current_state = $entity->moderation_state->entity; + + $transitions = $this->validation->getValidTransitions($entity, $this->currentUser()); + + // Exclude self-transitions. + $transitions = array_filter($transitions, function(ModerationStateTransition $transition) use ($current_state) { + return $transition->getToState() != $current_state->id(); + }); + + $target_states = []; + /** @var ModerationStateTransition $transition */ + foreach ($transitions as $transition) { + $target_states[$transition->getToState()] = $transition->label(); + } + + if (!count($target_states)) { + return $form; + } + + if ($current_state) { + $form['current'] = [ + '#type' => 'item', + '#title' => $this->t('Status'), + '#markup' => $current_state->label(), + ]; + } + + // Persist the entity so we can access it in the submit handler. + $form_state->set('entity', $entity); + + $form['new_state'] = [ + '#type' => 'select', + '#title' => $this->t('Moderate'), + '#options' => $target_states, + ]; + + $form['revision_log'] = [ + '#type' => 'textfield', + '#title' => $this->t('Log message'), + '#size' => 30, + ]; + + $form['submit'] = [ + '#type' => 'submit', + '#value' => $this->t('Apply'), + ]; + + $form['#theme'] = ['entity_moderation_form']; + + return $form; + } + + /** + * @inheritDoc + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + /** @var ContentEntityInterface $entity */ + $entity = $form_state->get('entity'); + + $new_state = $form_state->getValue('new_state'); + $entity->moderation_state->target_id = $new_state; + + $entity->revision_log = $form_state->getValue('revision_log'); + + $entity->save(); + + drupal_set_message($this->t('The moderation state has been updated.')); + + /** @var ModerationState $state */ + $state = $this->entityTypeManager->getStorage('moderation_state')->load($new_state); + + // The page we're on likely won't be visible if we just set the entity to + // the default state, as we hide that latest-revision tab if there is no + // forward revision. Redirect to the canonical URL instead, since that will + // still exist. + if ($state->isDefaultRevisionState()) { + $form_state->setRedirectUrl($entity->toUrl('canonical')); + } + } +} diff --git a/core/modules/content_moderation/src/Form/ModerationStateDeleteForm.php b/core/modules/content_moderation/src/Form/ModerationStateDeleteForm.php new file mode 100644 index 0000000..1bbec59 --- /dev/null +++ b/core/modules/content_moderation/src/Form/ModerationStateDeleteForm.php @@ -0,0 +1,51 @@ +t('Are you sure you want to delete %name?', array('%name' => $this->entity->label())); + } + + /** + * {@inheritdoc} + */ + public function getCancelUrl() { + return new Url('entity.moderation_state.collection'); + } + + /** + * {@inheritdoc} + */ + public function getConfirmText() { + return $this->t('Delete'); + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $this->entity->delete(); + + drupal_set_message( + $this->t('Moderation state %label deleted.', + [ + '%label' => $this->entity->label() + ] + ) + ); + + $form_state->setRedirectUrl($this->getCancelUrl()); + } + +} diff --git a/core/modules/content_moderation/src/Form/ModerationStateForm.php b/core/modules/content_moderation/src/Form/ModerationStateForm.php new file mode 100644 index 0000000..82c93a0 --- /dev/null +++ b/core/modules/content_moderation/src/Form/ModerationStateForm.php @@ -0,0 +1,85 @@ +entity; + $form['label'] = array( + '#type' => 'textfield', + '#title' => $this->t('Label'), + '#maxlength' => 255, + '#default_value' => $moderation_state->label(), + '#description' => $this->t("Label for the Moderation state."), + '#required' => TRUE, + ); + + $form['id'] = array( + '#type' => 'machine_name', + '#default_value' => $moderation_state->id(), + '#machine_name' => array( + 'exists' => '\Drupal\content_moderation\Entity\ModerationState::load', + ), + '#disabled' => !$moderation_state->isNew(), + ); + + $form['published'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Published'), + '#description' => $this->t('When content reaches this state it should be published.'), + '#default_value' => $moderation_state->isPublishedState(), + ]; + + $form['default_revision'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Default revision'), + '#description' => $this->t('When content reaches this state it should be made the default revision; this is implied for published states.'), + '#default_value' => $moderation_state->isDefaultRevisionState(), + // @todo When these are added, the checkbox default value does not apply properly. + // @see https://www.drupal.org/node/2645614 + // '#states' => [ + // 'checked' => [':input[name="published"]' => ['checked' => TRUE]], + // 'disabled' => [':input[name="published"]' => ['checked' => TRUE]], + // ], + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function save(array $form, FormStateInterface $form_state) { + $moderation_state = $this->entity; + $status = $moderation_state->save(); + + switch ($status) { + case SAVED_NEW: + drupal_set_message($this->t('Created the %label Moderation state.', [ + '%label' => $moderation_state->label(), + ])); + break; + + default: + drupal_set_message($this->t('Saved the %label Moderation state.', [ + '%label' => $moderation_state->label(), + ])); + } + $form_state->setRedirectUrl($moderation_state->toUrl('collection')); + } + +} diff --git a/core/modules/content_moderation/src/Form/ModerationStateTransitionDeleteForm.php b/core/modules/content_moderation/src/Form/ModerationStateTransitionDeleteForm.php new file mode 100644 index 0000000..caabc4b --- /dev/null +++ b/core/modules/content_moderation/src/Form/ModerationStateTransitionDeleteForm.php @@ -0,0 +1,51 @@ +t('Are you sure you want to delete %name?', array('%name' => $this->entity->label())); + } + + /** + * {@inheritdoc} + */ + public function getCancelUrl() { + return new Url('entity.moderation_state_transition.collection'); + } + + /** + * {@inheritdoc} + */ + public function getConfirmText() { + return $this->t('Delete'); + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $this->entity->delete(); + + drupal_set_message( + $this->t('Moderation transition %label deleted.', + [ + '%label' => $this->entity->label() + ] + ) + ); + + $form_state->setRedirectUrl($this->getCancelUrl()); + } + +} diff --git a/core/modules/content_moderation/src/Form/ModerationStateTransitionForm.php b/core/modules/content_moderation/src/Form/ModerationStateTransitionForm.php new file mode 100644 index 0000000..f6520d4 --- /dev/null +++ b/core/modules/content_moderation/src/Form/ModerationStateTransitionForm.php @@ -0,0 +1,141 @@ +entityTypeManager = $entity_type_manager; + $this->queryFactory = $query_factory; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static($container->get('entity_type.manager'), $container->get('entity.query')); + } + + /** + * {@inheritdoc} + */ + public function form(array $form, FormStateInterface $form_state) { + $form = parent::form($form, $form_state); + + /* @var \Drupal\content_moderation\ModerationStateTransitionInterface $moderation_state_transition */ + $moderation_state_transition = $this->entity; + $form['label'] = [ + '#type' => 'textfield', + '#title' => $this->t('Label'), + '#maxlength' => 255, + '#default_value' => $moderation_state_transition->label(), + '#description' => $this->t("Label for the Moderation state transition."), + '#required' => TRUE, + ]; + + $form['id'] = [ + '#type' => 'machine_name', + '#default_value' => $moderation_state_transition->id(), + '#machine_name' => [ + 'exists' => '\Drupal\content_moderation\Entity\ModerationStateTransition::load', + ], + '#disabled' => !$moderation_state_transition->isNew(), + ]; + + $options = []; + foreach ($this->entityTypeManager->getStorage('moderation_state')->loadMultiple() as $moderation_state) { + $options[$moderation_state->id()] = $moderation_state->label(); + } + + $form['container'] = [ + '#type' => 'container', + '#attributes' => [ + 'class' => ['container-inline'], + ], + ]; + + $form['container']['stateFrom'] = [ + '#type' => 'select', + '#title' => $this->t('Transition from'), + '#options' => $options, + '#required' => TRUE, + '#empty_option' => $this->t('-- Select --'), + '#default_value' => $moderation_state_transition->getFromState(), + ]; + + $form['container']['stateTo'] = [ + '#type' => 'select', + '#options' => $options, + '#required' => TRUE, + '#title' => $this->t('Transition to'), + '#empty_option' => $this->t('-- Select --'), + '#default_value' => $moderation_state_transition->getToState(), + ]; + + // Make sure there's always at least a wide enough delta on weight to cover + // the current value or the total number of transitions. That way we + // never end up forcing a transition to change its weight needlessly. + $num_transitions = $this->queryFactory->get('moderation_state_transition')->count()->execute(); + $delta = max(abs($moderation_state_transition->getWeight()), $num_transitions); + + $form['weight'] = [ + '#type' => 'weight', + '#delta' => $delta, + '#options' => $options, + '#title' => $this->t('Weight'), + '#default_value' => $moderation_state_transition->getWeight(), + '#description' => $this->t('Orders the transitions in moderation forms and the administrative listing. Heavier items will sink and the lighter items will be positioned nearer the top.'), + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function save(array $form, FormStateInterface $form_state) { + $moderation_state_transition = $this->entity; + $status = $moderation_state_transition->save(); + + switch ($status) { + case SAVED_NEW: + drupal_set_message($this->t('Created the %label Moderation state transition.', [ + '%label' => $moderation_state_transition->label(), + ])); + break; + + default: + drupal_set_message($this->t('Saved the %label Moderation state transition.', [ + '%label' => $moderation_state_transition->label(), + ])); + } + $form_state->setRedirectUrl($moderation_state_transition->toUrl('collection')); + } + +} diff --git a/core/modules/content_moderation/src/InlineEditingDisabler.php b/core/modules/content_moderation/src/InlineEditingDisabler.php new file mode 100644 index 0000000..69239ea --- /dev/null +++ b/core/modules/content_moderation/src/InlineEditingDisabler.php @@ -0,0 +1,52 @@ +moderationInfo = $moderation_info; + } + + /** + * Implements hook_entity_view_alter(). + */ + public function entityViewAlter(&$build, EntityInterface $entity, EntityViewDisplayInterface $display) { + if ($this->moderationInfo->isModeratableEntity($entity) && !$this->moderationInfo->isLatestRevision($entity)) { + // Hide quickedit, because its super confusing for the user to not edit the + // live revision. + unset($build['#attributes']['data-quickedit-entity-id']); + } + } + + /** + * Implements hook_module_implements_alter(). + */ + public function moduleImplementsAlter(&$implementations, $hook) { + if ($hook == 'entity_view_alter') { + // Find the quickedit implementation and move content after it. + unset($implementations['content_moderation']); + $implementations['content_moderation'] = FALSE; + } + } + +} diff --git a/core/modules/content_moderation/src/ModerationInformation.php b/core/modules/content_moderation/src/ModerationInformation.php new file mode 100644 index 0000000..51f5875 --- /dev/null +++ b/core/modules/content_moderation/src/ModerationInformation.php @@ -0,0 +1,216 @@ +entityTypeManager = $entity_type_manager; + $this->currentUser = $current_user; + } + + /** + * {@inheritdoc} + */ + public function isModeratableEntity(EntityInterface $entity) { + if (!$entity instanceof ContentEntityInterface) { + return FALSE; + } + + return $this->isModeratableBundle($entity->getEntityType(), $entity->bundle()); + } + + /** + * {@inheritdoc} + */ + public function isModeratableEntityType(EntityTypeInterface $entity_type) { + return $entity_type->hasHandlerClass('moderation'); + } + + /** + * {@inheritdoc} + */ + public function loadBundleEntity($bundle_entity_type_id, $bundle_id) { + if ($bundle_entity_type_id) { + return $this->entityTypeManager->getStorage($bundle_entity_type_id)->load($bundle_id); + } + } + + /** + * {@inheritdoc} + */ + public function isModeratableBundle(EntityTypeInterface $entity_type, $bundle) { + if ($bundle_entity = $this->loadBundleEntity($entity_type->getBundleEntityType(), $bundle)) { + return $bundle_entity->getThirdPartySetting('content_moderation', 'enabled', FALSE); + } + return FALSE; + } + + /** + * {@inheritdoc} + */ + public function selectRevisionableEntityTypes(array $entity_types) { + return array_filter($entity_types, function (EntityTypeInterface $type) use ($entity_types) { + return ($type instanceof ConfigEntityTypeInterface) + && ($bundle_of = $type->get('bundle_of')) + && $entity_types[$bundle_of]->isRevisionable(); + }); + } + + /** + * {@inheritdoc} + */ + public function selectRevisionableEntities(array $entity_types) { + return array_filter($entity_types, function (EntityTypeInterface $type) use ($entity_types) { + return ($type instanceof ContentEntityTypeInterface) + && $type->isRevisionable() + && $type->getBundleEntityType(); + }); + } + + /** + * {@inheritdoc} + */ + public function isBundleForModeratableEntity(EntityInterface $entity) { + $type = $entity->getEntityType(); + + return + $type instanceof ConfigEntityTypeInterface + && ($bundle_of = $type->get('bundle_of')) + && $this->entityTypeManager->getDefinition($bundle_of)->isRevisionable() + && $this->currentUser->hasPermission('administer moderation states'); + } + + /** + * {@inheritdoc} + */ + public function isModeratedEntityForm(FormInterface $form_object) { + return $form_object instanceof ContentEntityFormInterface + && $this->isModeratableEntity($form_object->getEntity()); + } + + /** + * {@inheritdoc} + */ + public function isRevisionableBundleForm(FormInterface $form_object) { + // We really shouldn't be checking for a base class, but core lacks an + // interface here. When core adds a better way to determine if we're on + // a Bundle configuration form we should switch to that. + if ($form_object instanceof BundleEntityFormBase) { + $bundle_of = $form_object->getEntity()->getEntityType()->getBundleOf(); + $type = $this->entityTypeManager->getDefinition($bundle_of); + return $type->isRevisionable(); + } + + return FALSE; + } + + /** + * {@inheritdoc} + */ + public function getLatestRevision($entity_type_id, $entity_id) { + if ($latest_revision_id = $this->getLatestRevisionId($entity_type_id, $entity_id)) { + return $this->entityTypeManager->getStorage($entity_type_id)->loadRevision($latest_revision_id); + } + } + + /** + * {@inheritdoc} + */ + public function getLatestRevisionId($entity_type_id, $entity_id) { + if ($storage = $this->entityTypeManager->getStorage($entity_type_id)) { + $revision_ids = $storage->getQuery() + ->allRevisions() + ->condition($this->entityTypeManager->getDefinition($entity_type_id)->getKey('id'), $entity_id) + ->sort($this->entityTypeManager->getDefinition($entity_type_id)->getKey('revision'), 'DESC') + ->range(0, 1) + ->execute(); + if ($revision_ids) { + $revision_id = array_keys($revision_ids)[0]; + return $revision_id; + } + } + } + + /** + * {@inheritdoc} + */ + public function getDefaultRevisionId($entity_type_id, $entity_id) { + if ($storage = $this->entityTypeManager->getStorage($entity_type_id)) { + $revision_ids = $storage->getQuery() + ->condition($this->entityTypeManager->getDefinition($entity_type_id)->getKey('id'), $entity_id) + ->sort($this->entityTypeManager->getDefinition($entity_type_id)->getKey('revision'), 'DESC') + ->range(0, 1) + ->execute(); + if ($revision_ids) { + $revision_id = array_keys($revision_ids)[0]; + return $revision_id; + } + } + } + + /** + * {@inheritdoc} + */ + public function isLatestRevision(ContentEntityInterface $entity) { + return $entity->getRevisionId() == $this->getLatestRevisionId($entity->getEntityTypeId(), $entity->id()); + } + + /** + * {@inheritdoc} + */ + public function hasForwardRevision(ContentEntityInterface $entity) { + return $this->isModeratableEntity($entity) + && !($this->getLatestRevisionId($entity->getEntityTypeId(), $entity->id()) == $this->getDefaultRevisionId($entity->getEntityTypeId(), $entity->id())); + } + + /** + * {@inheritdoc} + */ + public function isLiveRevision(ContentEntityInterface $entity) { + return $this->isLatestRevision($entity) + && $entity->isDefaultRevision() + && $entity->moderation_state->entity + && $entity->moderation_state->entity->isPublishedState(); + } +} + diff --git a/core/modules/content_moderation/src/ModerationInformationInterface.php b/core/modules/content_moderation/src/ModerationInformationInterface.php new file mode 100644 index 0000000..290a5eb --- /dev/null +++ b/core/modules/content_moderation/src/ModerationInformationInterface.php @@ -0,0 +1,211 @@ +t('Moderation state'); + $header['id'] = $this->t('Machine name'); + return $header + parent::buildHeader(); + } + + /** + * {@inheritdoc} + */ + public function buildRow(EntityInterface $entity) { + $row['label'] = $entity->label(); + $row['id'] = $entity->id(); + // You probably want a few more properties here... + return $row + parent::buildRow($entity); + } + +} diff --git a/core/modules/content_moderation/src/ModerationStateTransitionInterface.php b/core/modules/content_moderation/src/ModerationStateTransitionInterface.php new file mode 100644 index 0000000..035cf1e --- /dev/null +++ b/core/modules/content_moderation/src/ModerationStateTransitionInterface.php @@ -0,0 +1,47 @@ +get('entity.manager')->getStorage($entity_type->id()), + $container->get('entity.manager')->getStorage('moderation_state') + ); + } + + /** + * Constructs a new ModerationStateTransitionListBuilder. + * + * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type + * @param \Drupal\Core\Entity\EntityStorageInterface $transition_storage + * @param \Drupal\Core\Entity\EntityStorageInterface $state_storage + */ + public function __construct(EntityTypeInterface $entity_type, EntityStorageInterface $transition_storage, EntityStorageInterface $state_storage) { + parent::__construct($entity_type, $transition_storage); + $this->stateStorage = $state_storage; + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'content_moderation_transition_list'; + } + + /** + * {@inheritdoc} + */ + public function buildHeader() { + $header['label'] = $this->t('Moderation state transition'); + $header['id'] = $this->t('Machine name'); + $header['from'] = $this->t('From state'); + $header['to'] = $this->t('To state'); + return $header + parent::buildHeader(); + } + + /** + * {@inheritdoc} + */ + public function buildRow(EntityInterface $entity) { + /** @var ModerationStateTransitionInterface $entity */ + + $row['label'] = $entity->label(); + $row['id']['#markup'] = $entity->id(); + $row['from']['#markup'] = $this->stateStorage->load($entity->getFromState())->label(); + $row['to']['#markup'] = $this->stateStorage->load($entity->getToState())->label(); + + return $row + parent::buildRow($entity); + } + + /** + * {@inheritdoc} + */ + public function render() { + $build = parent::render(); // TODO: Change the autogenerated stub + + $build['item'] = [ + '#type' => 'item', + '#markup' => $this->t('When saving an entity, only a destination state that has a transition is legal. That includes its current state. If you want to allow an entity to be saved without changing its state then you must define a transition from that state to itself. Note that all users will still need permission to use a defined transition.'), + '#weight' => -5, + ]; + + return $build; + } + +} diff --git a/core/modules/content_moderation/src/ModerationStateTransitionStorage.php b/core/modules/content_moderation/src/ModerationStateTransitionStorage.php new file mode 100644 index 0000000..96ce0cb --- /dev/null +++ b/core/modules/content_moderation/src/ModerationStateTransitionStorage.php @@ -0,0 +1,68 @@ +get('config.factory'), + $container->get('uuid'), + $container->get('language_manager'), + $container->get('entity_type.manager') + ); + } + + /** + * {@inheritdoc} + */ + public function __construct(EntityTypeInterface $entity_type, ConfigFactoryInterface $config_factory, UuidInterface $uuid_service, LanguageManagerInterface $language_manager, EntityTypeManagerInterface $entity_type_manager) { + parent::__construct($entity_type, $config_factory, $uuid_service, $language_manager); + $this->entityTypeManager = $entity_type_manager; + } + + /** + * {@inheritdoc} + */ + protected function doCreate(array $values) { + /* @var \Drupal\content_moderation\ModerationStateTransitionInterface $entity */ + $entity = parent::doCreate($values); + return $entity->setModerationStateConfigPrefix($this->entityTypeManager->getDefinition('moderation_state')->getConfigPrefix()); + } + + /** + * {@inheritdoc} + */ + protected function mapFromStorageRecords(array $records) { + $entities = parent::mapFromStorageRecords($records); + $prefix = $this->entityTypeManager->getDefinition('moderation_state')->getConfigPrefix(); + /* @var \Drupal\content_moderation\ModerationStateTransitionInterface $entity */ + foreach ($entities as &$entity) { + $entity->setModerationStateConfigPrefix($prefix); + } + reset($entities); + return $entities; + } + +} diff --git a/core/modules/content_moderation/src/ParamConverter/EntityRevisionConverter.php b/core/modules/content_moderation/src/ParamConverter/EntityRevisionConverter.php new file mode 100644 index 0000000..33479cd --- /dev/null +++ b/core/modules/content_moderation/src/ParamConverter/EntityRevisionConverter.php @@ -0,0 +1,103 @@ +moderationInformation = $moderation_info; + } + + /** + * {@inheritdoc} + */ + public function applies($definition, $name, Route $route) { + return $this->hasForwardRevisionFlag($definition) || $this->isEditFormPage($route); + } + + /** + * Determines if the route definition includes a forward-revision flag. + * + * This is a custom flag defined by WBM to load forward revisions rather than + * the default revision on a given route. + * + * @param array $definition + * The parameter definition provided in the route options. + * + * @return bool + * TRUE if the forward revision flag is set, FALSE otherwise. + */ + protected function hasForwardRevisionFlag(array $definition) { + return (isset($definition['load_forward_revision']) && $definition['load_forward_revision']); + } + + /** + * Determines if a given route is the edit-form for an entity. + * + * @param \Symfony\Component\Routing\Route $route + * The route definition. + * + * @return bool + * Returns TRUE if the route is the edit form of an entity, FALSE otherwise. + */ + protected function isEditFormPage(Route $route) { + if ($default = $route->getDefault('_entity_form') ) { + // If no operation is provided, use 'default'. + $default .= '.default'; + list($entity_type_id, $operation) = explode('.', $default); + if (!$this->entityManager->hasDefinition($entity_type_id)) { + return FALSE; + } + $entity_type = $this->entityManager->getDefinition($entity_type_id); + return $operation == 'edit' && $entity_type && $entity_type->isRevisionable(); + } + } + + /** + * {@inheritdoc} + */ + public function convert($value, $definition, $name, array $defaults) { + $entity = parent::convert($value, $definition, $name, $defaults); + + if ($entity && $this->moderationInformation->isModeratableEntity($entity) && !$this->moderationInformation->isLatestRevision($entity)) { + $entity_type_id = $this->getEntityTypeFromDefaults($definition, $name, $defaults); + $entity = $this->moderationInformation->getLatestRevision($entity_type_id, $value); + + // If the entity type is translatable, ensure we return the proper + // translation object for the current context. + if ($entity instanceof EntityInterface && $entity instanceof TranslatableInterface) { + $entity = $this->entityManager->getTranslationFromContext($entity, NULL, array('operation' => 'entity_upcast')); + } + } + + return $entity; + } + +} diff --git a/core/modules/content_moderation/src/Permissions.php b/core/modules/content_moderation/src/Permissions.php new file mode 100644 index 0000000..90f509c --- /dev/null +++ b/core/modules/content_moderation/src/Permissions.php @@ -0,0 +1,45 @@ + $transition) { + $perms['use ' . $id . ' transition'] = [ + 'title' => $this->t('Use the %transition_name transition', [ + '%transition_name' => $transition->label(), + ]), + 'description' => $this->t('Move content from %from state to %to state.', [ + '%from' => $states[$transition->getFromState()]->label(), + '%to' => $states[$transition->getToState()]->label(), + ]), + ]; + } + + return $perms; + } + +} diff --git a/core/modules/content_moderation/src/Plugin/Action/ModerationOptOutPublishNode.php b/core/modules/content_moderation/src/Plugin/Action/ModerationOptOutPublishNode.php new file mode 100644 index 0000000..5def5a5 --- /dev/null +++ b/core/modules/content_moderation/src/Plugin/Action/ModerationOptOutPublishNode.php @@ -0,0 +1,60 @@ +moderationInfo = $mod_info; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, $plugin_id, $plugin_definition, + $container->get('content_moderation.moderation_information') + ); + } + + /** + * {@inheritdoc} + */ + public function execute($entity = NULL) { + if ($entity && $this->moderationInfo->isModeratableEntity($entity)) { + drupal_set_message($this->t('One or more entities were skipped as they are under moderation and may not be directly published or unpublished.')); + return; + } + + parent::execute($entity); + } + + /** + * {@inheritdoc} + */ + public function access($object, AccountInterface $account = NULL, $return_as_object = FALSE) { + $result = parent::access($object, $account, TRUE) + ->andif(AccessResult::forbiddenIf($this->moderationInfo->isModeratableEntity($object))->addCacheableDependency($object)); + + return $return_as_object ? $result : $result->isAllowed(); + } +} diff --git a/core/modules/content_moderation/src/Plugin/Action/ModerationOptOutUnpublishNode.php b/core/modules/content_moderation/src/Plugin/Action/ModerationOptOutUnpublishNode.php new file mode 100644 index 0000000..6222735 --- /dev/null +++ b/core/modules/content_moderation/src/Plugin/Action/ModerationOptOutUnpublishNode.php @@ -0,0 +1,60 @@ +moderationInfo = $mod_info; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, $plugin_id, $plugin_definition, + $container->get('content_moderation.moderation_information') + ); + } + + /** + * {@inheritdoc} + */ + public function execute($entity = NULL) { + if ($entity && $this->moderationInfo->isModeratableEntity($entity)) { + drupal_set_message($this->t('One or more entities were skipped as they are under moderation and may not be directly published or unpublished.')); + return; + } + + parent::execute($entity); + } + + /** + * {@inheritdoc} + */ + public function access($object, AccountInterface $account = NULL, $return_as_object = FALSE) { + $result = parent::access($object, $account, TRUE) + ->andif(AccessResult::forbiddenIf($this->moderationInfo->isModeratableEntity($object))->addCacheableDependency($object)); + + return $return_as_object ? $result : $result->isAllowed(); + } +} diff --git a/core/modules/content_moderation/src/Plugin/Derivative/DynamicLocalTasks.php b/core/modules/content_moderation/src/Plugin/Derivative/DynamicLocalTasks.php new file mode 100644 index 0000000..2c4ebaa --- /dev/null +++ b/core/modules/content_moderation/src/Plugin/Derivative/DynamicLocalTasks.php @@ -0,0 +1,124 @@ +entityTypeManager = $entity_type_manager; + $this->stringTranslation = $string_translation; + $this->basePluginId = $base_plugin_id; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, $base_plugin_id) { + return new static( + $base_plugin_id, + $container->get('entity_type.manager'), + $container->get('string_translation') + ); + } + + /** + * {@inheritdoc} + */ + public function getDerivativeDefinitions($base_plugin_definition) { + $this->derivatives = []; + + foreach ($this->moderatableEntityTypeDefinitions() as $entity_type_id => $entity_type) { + $this->derivatives["$entity_type_id.moderation_tab"] = [ + 'route_name' => "entity.$entity_type_id.moderation", + 'title' => $this->t('Manage moderation'), + // @todo - are we sure they all have an edit_form? + 'base_route' => "entity.$entity_type_id.edit_form", + 'weight' => 30, + ] + $base_plugin_definition; + } + + $latest_version_entities = array_filter($this->moderatableEntityDefinitions(), function (EntityTypeInterface $type) { + return $type->hasLinkTemplate('latest-version'); + }); + + foreach ($latest_version_entities as $entity_type_id => $entity_type) { + $this->derivatives["$entity_type_id.latest_version_tab"] = [ + 'route_name' => "entity.$entity_type_id.latest_version", + 'title' => $this->t('Latest version'), + 'base_route' => "entity.$entity_type_id.canonical", + 'weight' => 1, + ] + $base_plugin_definition; + } + + return $this->derivatives; + } + + /** + * Returns an array of content entities that are potentially moderateable. + * + * @return EntityTypeInterface[] + * An array of just those entities we care about. + */ + protected function moderatableEntityDefinitions() { + return array_filter($this->entityTypeManager->getDefinitions(), function (EntityTypeInterface $type) { + return ($type instanceof ContentEntityTypeInterface) + && $type->getBundleEntityType() + && $type->isRevisionable(); + }); + } + + /** + * Returns an iterable of the config entities representing moderatable content. + * + * @return EntityTypeInterface[] + * An array of just those entity types we care about. + */ + protected function moderatableEntityTypeDefinitions() { + $entity_types = $this->entityTypeManager->getDefinitions(); + + return array_filter($entity_types, function (EntityTypeInterface $type) use ($entity_types) { + return ($type instanceof ConfigEntityTypeInterface) + && ($bundle_of = $type->get('bundle_of')) + && $entity_types[$bundle_of]->isRevisionable(); + }); + } +} diff --git a/core/modules/content_moderation/src/Plugin/Field/FieldWidget/ModerationStateWidget.php b/core/modules/content_moderation/src/Plugin/Field/FieldWidget/ModerationStateWidget.php new file mode 100644 index 0000000..7b6404e --- /dev/null +++ b/core/modules/content_moderation/src/Plugin/Field/FieldWidget/ModerationStateWidget.php @@ -0,0 +1,267 @@ +moderationStateTransitionEntityQuery = $entity_query; + $this->moderationStateTransitionStorage = $moderation_state_transition_storage; + $this->moderationStateStorage = $moderation_state_storage; + $this->entityTypeManager = $entity_type_manager; + $this->currentUser = $current_user; + $this->moderationInformation = $moderation_information; + $this->validator = $validator; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $plugin_id, + $plugin_definition, + $configuration['field_definition'], + $configuration['settings'], + $configuration['third_party_settings'], + $container->get('current_user'), + $container->get('entity_type.manager'), + $container->get('entity_type.manager')->getStorage('moderation_state'), + $container->get('entity_type.manager')->getStorage('moderation_state_transition'), + $container->get('entity.query')->get('moderation_state_transition', 'AND'), + $container->get('content_moderation.moderation_information'), + $container->get('content_moderation.state_transition_validation') + ); + } + + /** + * {@inheritdoc} + */ + public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) { + /** @var ContentEntityInterface $entity */ + $entity = $items->getEntity(); + + /* @var \Drupal\Core\Config\Entity\ConfigEntityInterface $bundle_entity */ + $bundle_entity = $this->entityTypeManager->getStorage($entity->getEntityType()->getBundleEntityType())->load($entity->bundle()); + if (!$this->moderationInformation->isModeratableEntity($entity)) { + // @todo write a test for this. + return $element + ['#access' => FALSE]; + } + + $default = $items->get($delta)->target_id ?: $bundle_entity->getThirdPartySetting('content_moderation', 'default_moderation_state', FALSE); + /** @var \Drupal\content_moderation\ModerationStateInterface $default_state */ + $default_state = $this->entityTypeManager->getStorage('moderation_state')->load($default); + if (!$default || !$default_state) { + throw new \UnexpectedValueException(sprintf('The %s bundle has an invalid moderation state configuration, moderation states are enabled but no default is set.', $bundle_entity->label())); + } + + $transitions = $this->validator->getValidTransitions($entity, $this->currentUser); + + $target_states = []; + /** @var ModerationStateTransition $transition */ + foreach ($transitions as $transition) { + $target_states[$transition->getToState()] = $transition->label(); + } + + // @todo write a test for this. + $element += [ + '#access' => FALSE, + '#type' => 'select', + '#options' => $target_states, + '#default_value' => $default, + '#published' => $default ? $default_state->isPublishedState() : FALSE, + ]; + + // Use the dropbutton. + $element['#process'][] = [get_called_class(), 'processActions']; + return $element; + } + + /** + * Entity builder updating the node moderation state with the submitted value. + * + * @param string $entity_type_id + * The entity type identifier. + * @param \Drupal\Core\Entity\ContentEntityInterface $entity + * The entity updated with the submitted values. + * @param array $form + * The complete form array. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + */ + public static function updateStatus($entity_type_id, ContentEntityInterface $entity, array $form, FormStateInterface $form_state) { + $element = $form_state->getTriggeringElement(); + if (isset($element['#moderation_state'])) { + $entity->moderation_state->target_id = $element['#moderation_state']; + } + } + + /** + * Process callback to alter action buttons. + */ + public static function processActions($element, FormStateInterface $form_state, array &$form) { + + // We'll steal most of the button configuration from the default submit button. + // However, NodeForm also hides that button for admins (as it adds its own, + // too), so we have to restore it. + $default_button = $form['actions']['submit']; + $default_button['#access'] = TRUE; + + // Add a custom button for each transition we're allowing. The #dropbutton + // property tells FAPI to cluster them all together into a single widget. + $options = $element['#options']; + + $entity = $form_state->getFormObject()->getEntity(); + $translatable = !$entity->isNew() && $entity->isTranslatable(); + foreach ($options as $id => $label) { + $button = [ + '#dropbutton' => 'save', + '#moderation_state' => $id, + '#weight' => -10, + ]; + + $button['#value'] = $translatable + ? t('Save and @transition (this translation)', ['@transition' => $label]) + : t('Save and @transition', ['@transition' => $label]); + + + $form['actions']['moderation_state_' . $id] = $button + $default_button; + } + + // Hide the default buttons, including the specialty ones added by + // NodeForm. + foreach (['publish', 'unpublish', 'submit'] as $key) { + $form['actions'][$key]['#access'] = FALSE; + unset($form['actions'][$key]['#dropbutton']); + } + + // Setup a callback to translate the button selection back into field + // widget, so that it will get saved properly. + $form['#entity_builders']['update_moderation_state'] = [get_called_class(), 'updateStatus']; + return $element; + } + + /** + * {@inheritdoc} + */ + public static function isApplicable(FieldDefinitionInterface $field_definition) { + return parent::isApplicable($field_definition) && $field_definition->getName() === 'moderation_state'; + } + + /** + * {@inheritdoc} + */ + public function extractFormValues(FieldItemListInterface $items, array $form, FormStateInterface $form_state) { + $field_name = $this->fieldDefinition->getName(); + + // Extract the values from $form_state->getValues(). + $path = array_merge($form['#parents'], array($field_name)); + $key_exists = NULL; + // Convert the field value into expected array format. + $values = $form_state->getValues(); + $value = NestedArray::getValue($values, $path, $key_exists); + if (empty($value)) { + parent::extractFormValues($items, $form, $form_state); + return; + } + if (!isset($value[0]['target_id'])) { + NestedArray::setValue($values, $path, [['target_id' => reset($value)]]); + $form_state->setValues($values); + } + parent::extractFormValues($items, $form, $form_state); + } + +} diff --git a/core/modules/content_moderation/src/Plugin/Menu/EditTab.php b/core/modules/content_moderation/src/Plugin/Menu/EditTab.php new file mode 100644 index 0000000..f492574 --- /dev/null +++ b/core/modules/content_moderation/src/Plugin/Menu/EditTab.php @@ -0,0 +1,104 @@ +stringTranslation = $string_translation; + $this->moderationInfo = $moderation_information; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('string_translation'), + $container->get('content_moderation.moderation_information') + ); + } + + /** + * {@inheritdoc} + */ + public function getRouteParameters(RouteMatchInterface $route_match) { + // Override the node here with the latest revision. + $this->entity = $route_match->getParameter($this->pluginDefinition['entity_type_id']); + return parent::getRouteParameters($route_match); + } + + /** + * {@inheritdoc} + */ + public function getTitle() { + if (!$this->moderationInfo->isModeratableEntity($this->entity)) { + // Moderation isn't enabled. + return parent::getTitle(); + } + + // @todo write a test for this. + return $this->moderationInfo->isLiveRevision($this->entity) + ? $this->t('New draft') + : $this->t('Edit draft'); + } + + /** + * {@inheritdoc} + */ + public function getCacheTags() { + // @todo write a test for this. + $tags = parent::getCacheTags(); + // Tab changes if node or node-type is modified. + $tags = array_merge($tags, $this->entity->getCacheTags()); + $tags[] = $this->entity->getEntityType()->getBundleEntityType() . ':' . $this->entity->bundle(); + return $tags; + } + +} diff --git a/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationState.php b/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationState.php new file mode 100644 index 0000000..62c0454 --- /dev/null +++ b/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationState.php @@ -0,0 +1,21 @@ +validation = $validation; + $this->entityTypeManager = $entity_type_manager; + $this->moderationInformation = $moderation_information; + } + + public static function create(ContainerInterface $container) { + return new static( + $container->get('entity_type.manager'), + $container->get('content_moderation.state_transition_validation'), + $container->get('content_moderation.moderation_information') + ); + } + + /** + * {@inheritdoc} + */ + public function validate($value, Constraint $constraint) { + /** @var \Drupal\Core\Entity\EntityInterface $entity */ + $entity = $value->getEntity(); + + // Ignore entities that are not subject to moderation anyway. + if (!$this->moderationInformation->isModeratableEntity($entity)) { + return; + } + + // Ignore entities that are being created for the first time. + if ($entity->isNew()) { + return; + } + + // Ignore entities that are being moderated for the first time, such as + // when they existed before moderation was enabled for this entity type. + if ($this->isFirstTimeModeration($entity)) { + return; + } + + $original_entity = $this->moderationInformation->getLatestRevision($entity->getEntityTypeId(), $entity->id()); + if (!$entity->isDefaultTranslation() && $original_entity->hasTranslation($entity->language()->getId())) { + $original_entity = $original_entity->getTranslation($entity->language()->getId()); + } + $next_moderation_state_id = $entity->moderation_state->target_id; + $original_moderation_state_id = $original_entity->moderation_state->target_id; + + if (!$this->validation->isTransitionAllowed($original_moderation_state_id, $next_moderation_state_id)) { + $this->context->addViolation($constraint->message, ['%from' => $original_entity->moderation_state->entity->label(), '%to' => $entity->moderation_state->entity->label()]); + } + } + + /** + * Determines if this entity is being moderated for the first time. + * + * If the previous version of the entity has no moderation state, we assume + * that means it predates the presence of moderation states. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * + * @return bool + * TRUE if this is the entity's first time being moderated, FALSE otherwise. + */ + protected function isFirstTimeModeration(EntityInterface $entity) { + $original_entity = $this->moderationInformation->getLatestRevision($entity->getEntityTypeId(), $entity->id()); + + $original_id = $original_entity->moderation_state->target_id; + + return !($entity->moderation_state->target_id && $original_entity && $original_id); + } + +} diff --git a/core/modules/content_moderation/src/Plugin/views/filter/LatestRevision.php b/core/modules/content_moderation/src/Plugin/views/filter/LatestRevision.php new file mode 100644 index 0000000..c65b194 --- /dev/null +++ b/core/modules/content_moderation/src/Plugin/views/filter/LatestRevision.php @@ -0,0 +1,121 @@ +entityTypeManager = $entity_type_manager; + $this->joinHandler = $join_handler; + $this->connection = $connection; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, $plugin_id, $plugin_definition, + $container->get('entity_type.manager'), + $container->get('plugin.manager.views.join'), + $container->get('database') + ); + } + + /** + * {@inheritdoc} + */ + public function adminSummary() { } + + /** + * {@inheritdoc} + */ + protected function operatorForm(&$form, FormStateInterface $form_state) { } + + /** + * {@inheritdoc} + */ + public function canExpose() { return FALSE; } + + /** + * {@inheritdoc} + */ + public function query() { + // The table doesn't exist until a moderated node has been saved at least + // once. Just in case, disable this filter until then. Note that this means + // the view will still show all revisions, not just latest, but this is + // sufficiently edge-case-y that it's probably not worth the time to + // handle more robustly. + if (!$this->connection->schema()->tableExists('content_revision_tracker')) { + return; + } + + $table = $this->ensureMyTable(); + + /** @var Sql $query */ + $query = $this->query; + + $definition = $this->entityTypeManager->getDefinition($this->getEntityType()); + $keys = $definition->getKeys(); + + $definition = [ + 'table' => 'content_revision_tracker', + 'type' => 'INNER', + 'field' => 'entity_id', + 'left_table' => $table, + 'left_field' => $keys['id'], + 'extra' => [ + ['left_field' => $keys['langcode'], 'field' => 'langcode'], + ['left_field' => $keys['revision'], 'field' => 'revision_id'], + ['field' => 'entity_type', 'value' => $this->getEntityType()], + ], + ]; + + $join = $this->joinHandler->createInstance('standard', $definition); + + $query->ensureTable('content_revision_tracker', $this->relationship, $join); + } +} diff --git a/core/modules/content_moderation/src/RevisionTracker.php b/core/modules/content_moderation/src/RevisionTracker.php new file mode 100644 index 0000000..010128c --- /dev/null +++ b/core/modules/content_moderation/src/RevisionTracker.php @@ -0,0 +1,154 @@ +connection = $connection; + $this->tableName = $table; + } + + /** + * {@inheritdoc} + */ + public function setLatestRevision($entity_type, $entity_id, $langcode, $revision_id) { + try { + $this->recordLatestRevision($entity_type, $entity_id, $langcode, $revision_id); + } + catch (DatabaseExceptionWrapper $e) { + $this->ensureTableExists(); + $this->recordLatestRevision($entity_type, $entity_id, $langcode, $revision_id); + } + + return $this; + } + + /** + * Records the latest revision of a given entity. + * + * @param $entity_type + * The machine name of the type of entity. + * @param $entity_id + * The Entity ID in question. + * @param $langcode + * The langcode of the revision we're saving. Each language has its own + * effective tree of entity revisions, so in different languages + * different revisions will be "latest". + * @param $revision_id + * The revision ID that is now the latest revision. + * + * @return int + * One of the valid returns from a merge query's execute method. + */ + protected function recordLatestRevision($entity_type, $entity_id, $langcode, $revision_id) { + return $this->connection->merge($this->tableName) + ->keys([ + 'entity_type' => $entity_type, + 'entity_id' => $entity_id, + 'langcode' => $langcode, + ]) + ->fields([ + 'revision_id' => $revision_id, + ]) + ->execute(); + } + + /** + * Checks if the table exists and create it if not. + * + * @return bool + * TRUE if the table was created, FALSE otherwise. + */ + protected function ensureTableExists() { + try { + if (!$this->connection->schema()->tableExists($this->tableName)) { + $this->connection->schema()->createTable($this->tableName, $this->schemaDefinition()); + return TRUE; + } + } + catch (SchemaObjectExistsException $e) { + // If another process has already created the table, attempting to + // recreate it will throw an exception. In this case just catch the + // exception and do nothing. + return TRUE; + } + return FALSE; + } + + /** + * Defines the schema for the tracker table. + * + * @return array + * The schema API definition for the SQL storage table. + */ + protected function schemaDefinition() { + $schema = [ + 'description' => 'Tracks the latest revision for any entity', + 'fields' => [ + 'entity_type' => [ + 'description' => 'The entity type', + 'type' => 'varchar_ascii', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + ], + 'entity_id' => [ + 'description' => 'The entity ID', + 'type' => 'int', + 'length' => 255, + 'not null' => TRUE, + 'default' => 0, + ], + 'langcode' => [ + 'description' => 'The language of the entity revision', + 'type' => 'varchar', + 'length' => 12, + 'not null' => TRUE, + 'default' => '', + ], + 'revision_id' => [ + 'description' => 'The latest revision ID for this entity', + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + ], + ], + 'primary key' => ['entity_type', 'entity_id', 'langcode'], + ]; + + return $schema; + } + +} diff --git a/core/modules/content_moderation/src/RevisionTrackerInterface.php b/core/modules/content_moderation/src/RevisionTrackerInterface.php new file mode 100644 index 0000000..5711743 --- /dev/null +++ b/core/modules/content_moderation/src/RevisionTrackerInterface.php @@ -0,0 +1,27 @@ +entityFieldManager = $entity_manager; + } + + /** + * {@inheritdoc} + */ + public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) { + return new static( + $container->get('entity_field.manager') + ); + } + + /** + * {@inheritdoc} + */ + public function getRoutes(EntityTypeInterface $entity_type) { + $collection = new RouteCollection(); + + if ($moderation_route = $this->getLatestVersionRoute($entity_type)) { + $entity_type_id = $entity_type->id(); + $collection->add("entity.{$entity_type_id}.latest_version", $moderation_route); + } + + return $collection; + } + + /** + * Gets the moderation-form route. + * + * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type + * The entity type. + * + * @return \Symfony\Component\Routing\Route|null + * The generated route, if available. + */ + protected function getLatestVersionRoute(EntityTypeInterface $entity_type) { + if ($entity_type->hasLinkTemplate('latest-version') && $entity_type->hasViewBuilderClass()) { + $entity_type_id = $entity_type->id(); + $route = new Route($entity_type->getLinkTemplate('latest-version')); + $route + ->addDefaults([ + '_entity_view' => "{$entity_type_id}.full", + '_title_callback' => '\Drupal\Core\Entity\Controller\EntityController::title', + ]) + // If the entity type is a node, unpublished content will be visible + // if the user has the "view all unpublished content" permission. + ->setRequirement('_entity_access', "{$entity_type_id}.view") + ->setRequirement('_permission', 'view latest version,view any unpublished content') + ->setRequirement('_content_moderation_latest_version', 'TRUE') + ->setOption('_content_moderation_entity_type', $entity_type_id) + ->setOption('parameters', [ + $entity_type_id => ['type' => 'entity:' . $entity_type_id, 'load_forward_revision' => 1], + ]); + + // Entity types with serial IDs can specify this in their route + // requirements, improving the matching process. + if ($this->getEntityTypeIdKeyType($entity_type) === 'integer') { + $route->setRequirement($entity_type_id, '\d+'); + } + return $route; + } + } + + /** + * Gets the type of the ID key for a given entity type. + * + * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type + * An entity type. + * + * @return string|null + * The type of the ID key for a given entity type, or NULL if the entity + * type does not support fields. + */ + protected function getEntityTypeIdKeyType(EntityTypeInterface $entity_type) { + if (!$entity_type->isSubclassOf(FieldableEntityInterface::class)) { + return NULL; + } + + $field_storage_definitions = $this->entityFieldManager->getFieldStorageDefinitions($entity_type->id()); + return $field_storage_definitions[$entity_type->getKey('id')]->getType(); + } +} diff --git a/core/modules/content_moderation/src/Routing/EntityTypeModerationRouteProvider.php b/core/modules/content_moderation/src/Routing/EntityTypeModerationRouteProvider.php new file mode 100644 index 0000000..35287b4 --- /dev/null +++ b/core/modules/content_moderation/src/Routing/EntityTypeModerationRouteProvider.php @@ -0,0 +1,58 @@ +getModerationFormRoute($entity_type)) { + $entity_type_id = $entity_type->id(); + $collection->add("entity.{$entity_type_id}.moderation", $moderation_route); + } + + return $collection; + } + + /** + * Gets the moderation-form route. + * + * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type + * The entity type. + * + * @return \Symfony\Component\Routing\Route|null + * The generated route, if available. + */ + protected function getModerationFormRoute(EntityTypeInterface $entity_type) { + if ($entity_type->hasLinkTemplate('moderation-form') && $entity_type->getFormClass('moderation')) { + $entity_type_id = $entity_type->id(); + + $route = new Route($entity_type->getLinkTemplate('moderation-form')); + + $route + ->setDefaults([ + '_entity_form' => "{$entity_type_id}.moderation", + '_title' => 'Moderation', + //'_title_callback' => '\Drupal\Core\Entity\Controller\EntityController::editTitle' + ]) + ->setRequirement('_permission', 'administer moderation states') // @todo Come up with a new permission. + ->setOption('parameters', [ + $entity_type_id => ['type' => 'entity:' . $entity_type_id], + ]); + + return $route; + } + } +} diff --git a/core/modules/content_moderation/src/StateTransitionValidation.php b/core/modules/content_moderation/src/StateTransitionValidation.php new file mode 100644 index 0000000..c9b1eab --- /dev/null +++ b/core/modules/content_moderation/src/StateTransitionValidation.php @@ -0,0 +1,282 @@ +entityTypeManager = $entity_type_manager; + $this->queryFactory = $query_factory; + } + + /** + * Computes a mapping of possible transitions. + * + * This method is uncached and will recalculate the list on every request. + * In most cases you want to use getPossibleTransitions() instead. + * + * @see static::getPossibleTransitions() + * + * @return array[] + * An array containing all possible transitions. Each entry is keyed by the + * "from" state, and the value is an array of all legal "to" states based + * on the currently defined transition objects. + */ + protected function calculatePossibleTransitions() { + $transitions = $this->transitionStorage()->loadMultiple(); + + $possible_transitions = []; + /** @var \Drupal\content_moderation\ModerationStateTransitionInterface $transition */ + foreach ($transitions as $transition) { + $possible_transitions[$transition->getFromState()][] = $transition->getToState(); + } + return $possible_transitions; + } + + /** + * Returns a mapping of possible transitions. + * + * @return array[] + * An array containing all possible transitions. Each entry is keyed by the + * "from" state, and the value is an array of all legal "to" states based + * on the currently defined transition objects. + */ + protected function getPossibleTransitions() { + if (empty($this->possibleTransitions)) { + $this->possibleTransitions = $this->calculatePossibleTransitions(); + } + return $this->possibleTransitions; + } + + /** + * Gets a list of states a user may transition an entity to. + * + * @param \Drupal\Core\Entity\ContentEntityInterface $entity + * The entity to be transitioned. + * @param \Drupal\Core\Session\AccountInterface $user + * The account that wants to perform a transition. + * + * @return ModerationState[] + * Returns an array of States to which the specified user may transition the + * entity. + */ + public function getValidTransitionTargets(ContentEntityInterface $entity, AccountInterface $user) { + $bundle = $this->loadBundleEntity($entity->getEntityType()->getBundleEntityType(), $entity->bundle()); + + $states_for_bundle = $bundle->getThirdPartySetting('content_moderation', 'allowed_moderation_states', []); + + /** @var ModerationState $state */ + $state = $entity->moderation_state->entity; + $current_state_id = $state->id(); + + $all_transitions = $this->getPossibleTransitions(); + $destinations = $all_transitions[$current_state_id]; + + $destinations = array_intersect($states_for_bundle, $destinations); + + $permitted_destinations = array_filter($destinations, function($state_name) use ($current_state_id, $user) { + return $this->userMayTransition($current_state_id, $state_name, $user); + }); + + return $this->entityTypeManager->getStorage('moderation_state')->loadMultiple($permitted_destinations); + } + + /** + * Gets a list of transitions that are legal for this user on this entity. + * + * @param \Drupal\Core\Entity\ContentEntityInterface $entity + * The entity to be transitioned. + * @param \Drupal\Core\Session\AccountInterface $user + * The account that wants to perform a transition. + * + * @param \Drupal\Core\Entity\ContentEntityInterface $entity + * @param \Drupal\Core\Session\AccountInterface $user + * + * @return ModerationStateTransition[] + */ + public function getValidTransitions(ContentEntityInterface $entity, AccountInterface $user) { + $bundle = $this->loadBundleEntity($entity->getEntityType()->getBundleEntityType(), $entity->bundle()); + + /** @var ModerationState $current_state */ + $current_state = $entity->moderation_state->entity; + $current_state_id = $current_state ? $current_state->id(): $bundle->getThirdPartySetting('content_moderation', 'default_moderation_state'); + + // Determine the states that are legal on this bundle. + $legal_bundle_states = $bundle->getThirdPartySetting('content_moderation', 'allowed_moderation_states', []); + + // Legal transitions include those that are possible from the current state, + // filtered by those whose target is legal on this bundle and that the + // user has access to execute. + $transitions = array_filter($this->getTransitionsFrom($current_state_id), function(ModerationStateTransition $transition) use ($legal_bundle_states, $user) { + return in_array($transition->getToState(), $legal_bundle_states) + && $user->hasPermission('use ' . $transition->id() . ' transition'); + }); + + return $transitions; + } + + /** + * Returns a list of transitions from a given state. + * + * This list is based only on those transitions that exist, not what + * transitions are legal in a given context. + * + * @param string $state_name + * The machine name of the state from which we are transitioning. + * + * @return ModerationStateTransition[] + */ + protected function getTransitionsFrom($state_name) { + $result = $this->transitionStateQuery() + ->condition('stateFrom', $state_name) + ->sort('weight') + ->execute(); + + return $this->transitionStorage()->loadMultiple($result); + } + + /** + * Determines if a user is allowed to transition from one state to another. + * + * This method will also return FALSE if there is no transition between the + * specified states at all. + * + * @param string $from + * The origin state machine name. + * @param string $to + * The desetination state machine name. + * @param \Drupal\Core\Session\AccountInterface $user + * The user to validate. + * + * @return bool + * TRUE if the given user may transition between those two states. + */ + public function userMayTransition($from, $to, AccountInterface $user) { + if ($transition = $this->getTransitionFromStates($from, $to)) { + return $user->hasPermission('use ' . $transition->id() . ' transition'); + } + return FALSE; + } + + /** + * Returns the transition object that transitions from one state to another. + * + * @param string $from + * The name of the "from" state. + * @param string $to + * The name of the "to" state. + * + * @return ModerationStateTransition|null + * A transition object, or NULL if there is no such transition in the system. + */ + protected function getTransitionFromStates($from, $to) { + $from = $this->transitionStateQuery() + ->condition('stateFrom', $from) + ->condition('stateTo', $to) + ->execute(); + + $transitions = $this->transitionStorage()->loadMultiple($from); + + if ($transitions) { + return current($transitions); + } + return NULL; + } + + /** + * Determines a transition allowed. + * + * @param string $from + * The from state. + * @param string $to + * The to state. + * + * @return bool + * Is the transition allowed. + */ + public function isTransitionAllowed($from, $to) { + $allowed_transitions = $this->calculatePossibleTransitions(); + if (isset($allowed_transitions[$from])) { + return in_array($to, $allowed_transitions[$from], TRUE); + } + return FALSE; + } + + /** + * + * @return \Drupal\Core\Entity\Query\QueryInterface + * A transition state query. + */ + protected function transitionStateQuery() { + return $this->queryFactory->get('moderation_state_transition', 'AND'); + } + + /** + * Returns the transition entity storage service. + * + * @return \Drupal\Core\Entity\EntityStorageInterface + */ + protected function transitionStorage() { + return $this->entityTypeManager->getStorage('moderation_state_transition'); + } + + /** + * Returns the state entity storage service. + * + * @return \Drupal\Core\Entity\EntityStorageInterface + */ + protected function stateStorage() { + return $this->entityTypeManager->getStorage('moderation_state'); + } + + /** + * Loads a specific bundle entity. + * + * @param string $bundle_entity_type_id + * The bundle entity type ID. + * @param string $bundle_id + * The bundle ID. + * + * @return \Drupal\Core\Config\Entity\ConfigEntityInterface|null + */ + protected function loadBundleEntity($bundle_entity_type_id, $bundle_id) { + if ($bundle_entity_type_id) { + return $this->entityTypeManager->getStorage($bundle_entity_type_id)->load($bundle_id); + } + } +} diff --git a/core/modules/content_moderation/src/Tests/ModerationFormTest.php b/core/modules/content_moderation/src/Tests/ModerationFormTest.php new file mode 100644 index 0000000..fcb1b79 --- /dev/null +++ b/core/modules/content_moderation/src/Tests/ModerationFormTest.php @@ -0,0 +1,112 @@ +drupalLogin($this->adminUser); + $this->createContentTypeFromUI('Moderated content', 'moderated_content', TRUE, [ + 'draft', + 'needs_review', + 'published' + ], 'draft'); + $this->grantUserPermissionToCreateContentOfType($this->adminUser, 'moderated_content'); + } + + /** + * Tests the moderation form that shows on the latest version page. + * + * The latest version page only shows if there is a forward revision. There + * is only a forward revision if a draft revision is created on a node where + * the default revision is not a published moderation state. + * + * @see \Drupal\content_moderation\EntityOperations + * @see \Drupal\content_moderation\Tests\ModerationStateBlockTest::testCustomBlockModeration + */ + public function testModerationForm() { + // Create new moderated content in draft. + $this->drupalPostForm('node/add/moderated_content', [ + 'title[0][value]' => 'Some moderated content', + 'body[0][value]' => 'First version of the content.', + ], t('Save and Create New Draft')); + + $node = $this->drupalGetNodeByTitle('Some moderated content'); + $canonical_path = sprintf('node/%d', $node->id()); + $edit_path = sprintf('node/%d/edit', $node->id()); + $latest_version_path = sprintf('node/%d/latest', $node->id()); + + $this->assertTrue($this->adminUser->hasPermission('edit any moderated_content content')); + + // The latest version page should not show, because there is no forward + // revision. + $this->drupalGet($latest_version_path); + $this->assertResponse(403); + + // Update the draft. + $this->drupalPostForm($edit_path, [ + 'body[0][value]' => 'Second version of the content.', + ], t('Save and Request Review')); + + // The latest version page should not show, because there is still no + // forward revision. + $this->drupalGet($latest_version_path); + $this->assertResponse(403); + + // Publish the draft. + $this->drupalPostForm($edit_path, [ + 'body[0][value]' => 'Third version of the content.', + ], t('Save and Publish')); + + // The published view should not have a moderation form, because it is the + // default revision. + $this->drupalGet($canonical_path); + $this->assertResponse(200); + $this->assertNoText('Status', 'The node view page has no moderation form.'); + + // The latest version page should not show, because there is still no + // forward revision. + $this->drupalGet($latest_version_path); + $this->assertResponse(403); + + // Make a forward revision. + $this->drupalPostForm($edit_path, [ + 'body[0][value]' => 'Fourth version of the content.', + ], t('Save and Create New Draft')); + + // The published view should not have a moderation form, because it is the + // default revision. + $this->drupalGet($canonical_path); + $this->assertResponse(200); + $this->assertNoText('Status', 'The node view page has no moderation form.'); + + // The latest version page should show the moderation form and have "Draft" + // status, because the forward revision is in "Draft". + $this->drupalGet($latest_version_path); + $this->assertResponse(200); + $this->assertText('Status', 'Form text found on the latest-version page.'); + $this->assertText('Draft', 'Correct status found on the latest-version page.'); + + // Submit the moderation form to change status to needs review. + $this->drupalPostForm($latest_version_path, [ + 'new_state' => 'needs_review', + ], t('Apply')); + + // The latest version page should show the moderation form and have "Needs + // Review" status, because the forward revision is in "Needs Review". + $this->drupalGet($latest_version_path); + $this->assertResponse(200); + $this->assertText('Status', 'Form text found on the latest-version page.'); + $this->assertText('Needs Review', 'Correct status found on the latest-version page.'); + } + +} diff --git a/core/modules/content_moderation/src/Tests/ModerationLocaleTest.php b/core/modules/content_moderation/src/Tests/ModerationLocaleTest.php new file mode 100644 index 0000000..ec835c6 --- /dev/null +++ b/core/modules/content_moderation/src/Tests/ModerationLocaleTest.php @@ -0,0 +1,171 @@ +drupalLogin($this->rootUser); + + // Enable moderation on Article node type. + $this->createContentTypeFromUI('Article', 'article', TRUE, ['draft', 'published', 'archived'], 'draft'); + + // Add French language. + $edit = [ + 'predefined_langcode' => 'fr', + ]; + $this->drupalPostForm('admin/config/regional/language/add', $edit, t('Add language')); + + // Enable content translation on articles. + $this->drupalGet('admin/config/regional/content-language'); + $edit = [ + 'entity_types[node]' => TRUE, + 'settings[node][article][translatable]' => TRUE, + 'settings[node][article][settings][language][language_alterable]' => TRUE, + ]; + $this->drupalPostForm(NULL, $edit, t('Save configuration')); + + // Create a published article in English. + $edit = [ + 'title[0][value]' => 'Published English node', + 'langcode[0][value]' => 'en', + ]; + $this->drupalPostForm('node/add/article', $edit, t('Save and Publish')); + $this->assertText(t('Article Published English node has been created.')); + $english_node = $this->drupalGetNodeByTitle('Published English node'); + + // Add a French translation. + $this->drupalGet('node/' . $english_node->id() . '/translations'); + $this->clickLink(t('Add')); + $edit = [ + 'title[0][value]' => 'French node Draft', + ]; + $this->drupalPostForm(NULL, $edit, t('Save and Create New Draft (this translation)')); + // Here the error has occured "The website encountered an unexpected error. + // Please try again later." + // If the translation has got lost. + $this->assertText(t('Article French node Draft has been updated.')); + $english_node = $this->drupalGetNodeByTitle('Published English node', TRUE); + $french_node = $english_node->getTranslation('fr'); + + // Create an article in English. + $edit = [ + 'title[0][value]' => 'English node', + 'langcode[0][value]' => 'en', + ]; + $this->drupalPostForm('node/add/article', $edit, t('Save and Create New Draft')); + $this->assertText(t('Article English node has been created.')); + $english_node = $this->drupalGetNodeByTitle('English node'); + + // Add a French translation. + $this->drupalGet('node/' . $english_node->id() . '/translations'); + $this->clickLink(t('Add')); + $edit = [ + 'title[0][value]' => 'French node', + ]; + $this->drupalPostForm(NULL, $edit, t('Save and Create New Draft (this translation)')); + $this->assertText(t('Article French node has been updated.')); + $english_node = $this->drupalGetNodeByTitle('English node', TRUE); + $french_node = $english_node->getTranslation('fr'); + + // Publish the English article and check that the translation stays + // unpublished. + $this->drupalPostForm('node/' . $english_node->id() . '/edit', [], t('Save and Publish (this translation)')); + $this->assertText(t('Article English node has been updated.')); + $english_node = $this->drupalGetNodeByTitle('English node', TRUE); + $french_node = $english_node->getTranslation('fr'); + $this->assertEqual($english_node->moderation_state->target_id, 'published'); + $this->assertTrue($english_node->isPublished()); + $this->assertEqual($french_node->moderation_state->target_id, 'draft'); + $this->assertFalse($french_node->isPublished()); + + // Create another article with its translation. This time we will publish + // the translation first. + $edit = [ + 'title[0][value]' => 'Another node', + ]; + $this->drupalPostForm('node/add/article', $edit, t('Save and Create New Draft')); + $this->assertText(t('Article Another node has been created.')); + $english_node = $this->drupalGetNodeByTitle('Another node'); + + // Add a French translation. + $this->drupalGet('node/' . $english_node->id() . '/translations'); + $this->clickLink(t('Add')); + $edit = [ + 'title[0][value]' => 'Translated node', + ]; + $this->drupalPostForm(NULL, $edit, t('Save and Create New Draft (this translation)')); + $this->assertText(t('Article Translated node has been updated.')); + $english_node = $this->drupalGetNodeByTitle('Another node', TRUE); + $french_node = $english_node->getTranslation('fr'); + + // Publish the translation and check that the source language version stays + // unpublished + $this->drupalPostForm('fr/node/' . $english_node->id() . '/edit', [], t('Save and Publish (this translation)')); + $this->assertText(t('Article Translated node has been updated.')); + $english_node = $this->drupalGetNodeByTitle('Another node', TRUE); + $french_node = $english_node->getTranslation('fr'); + $this->assertEqual($french_node->moderation_state->target_id, 'published'); + $this->assertTrue($french_node->isPublished()); + $this->assertEqual($english_node->moderation_state->target_id, 'draft'); + $this->assertFalse($english_node->isPublished()); + + // Now check that we can create a new draft of the translation and then + // publish it. + $edit = [ + 'title[0][value]' => 'New draft of translated node', + ]; + $this->drupalPostForm('fr/node/' . $english_node->id() . '/edit', $edit, t('Save and Create New Draft (this translation)')); + $this->assertText(t('Article New draft of translated node has been updated.')); + $english_node = $this->drupalGetNodeByTitle('Another node', TRUE); + $french_node = $english_node->getTranslation('fr'); + $this->assertEqual($french_node->moderation_state->target_id, 'published'); + $this->assertTrue($french_node->isPublished()); + $this->assertEqual($french_node->getTitle(), 'Translated node', 'The default revision of the published translation remains the same.'); + + // Publish the draft. + $edit = [ + 'new_state' => 'published', + ]; + $this->drupalPostForm('fr/node/' . $english_node->id() . '/latest', $edit, t('Apply')); + $this->assertText(t('The moderation state has been updated.')); + $english_node = $this->drupalGetNodeByTitle('Another node', TRUE); + $french_node = $english_node->getTranslation('fr'); + $this->assertEqual($french_node->moderation_state->target_id, 'published'); + $this->assertTrue($french_node->isPublished()); + $this->assertEqual($french_node->getTitle(), 'New draft of translated node', 'The draft has replaced the published revision.'); + + // Publish the English article before testing the archive transition. + $this->drupalPostForm('node/' . $english_node->id() . '/edit', [], t('Save and Publish (this translation)')); + $this->assertText(t('Article Another node has been updated.')); + + // Archive the node and its translation. + $this->drupalPostForm('node/' . $english_node->id() . '/edit', [], t('Save and Archive (this translation)')); + $this->assertText(t('Article Another node has been updated.')); + $this->drupalPostForm('fr/node/' . $english_node->id() . '/edit', [], t('Save and Archive (this translation)')); + $this->assertText(t('Article New draft of translated node has been updated.')); + $english_node = $this->drupalGetNodeByTitle('Another node', TRUE); + $french_node = $english_node->getTranslation('fr'); + $this->assertEqual($english_node->moderation_state->target_id, 'archived'); + $this->assertFalse($english_node->isPublished()); + $this->assertEqual($french_node->moderation_state->target_id, 'archived'); + $this->assertFalse($french_node->isPublished()); + } + +} diff --git a/core/modules/content_moderation/src/Tests/ModerationStateBlockTest.php b/core/modules/content_moderation/src/Tests/ModerationStateBlockTest.php new file mode 100644 index 0000000..a99c706 --- /dev/null +++ b/core/modules/content_moderation/src/Tests/ModerationStateBlockTest.php @@ -0,0 +1,131 @@ + 'basic', + 'label' => 'basic', + 'revision' => FALSE, + ]); + $bundle->save(); + + // Add the body field to it. + block_content_add_body_field($bundle->id()); + } + + /** + * Tests moderating custom blocks. + * + * Blocks and any non-node-type-entities do not have a concept of + * "published". As such, we must use the "default revision" to know what is + * going to be "published", i.e. visible to the user. + * + * The one exception is a block that has never been "published". When a block + * is first created, it becomes the "default revision". For each edit of the + * block after that, Content Moderation checks the "default revision" to + * see if it is set to a published moderation state. If it is not, the entity + * being saved will become the "default revision". + * + * The test below is intended, in part, to make this behavior clear. + * + * @see \Drupal\content_moderation\EntityOperations::entityPresave + * @see \Drupal\content_moderation\Tests\ModerationFormTest::testModerationForm + */ + public function testCustomBlockModeration() { + $this->drupalLogin($this->rootUser); + + // Enable moderation for custom blocks at admin/structure/block/block-content/manage/basic/moderation. + $edit = [ + 'enable_moderation_state' => TRUE, + 'allowed_moderation_states_unpublished[draft]' => TRUE, + 'allowed_moderation_states_published[published]' => TRUE, + 'default_moderation_state' => 'draft', + ]; + $this->drupalPostForm('admin/structure/block/block-content/manage/basic/moderation', $edit, t('Save')); + $this->assertText(t('Your settings have been saved.')); + + // Create a custom block at block/add and save it as draft. + $body = 'Body of moderated block'; + $edit = [ + 'info[0][value]' => 'Moderated block', + 'body[0][value]' => $body, + ]; + $this->drupalPostForm('block/add', $edit, t('Save and Create New Draft')); + $this->assertText(t('basic Moderated block has been created.')); + + // Place the block in the Sidebar First region. + $instance = array( + 'id' => 'moderated_block', + 'settings[label]' => $edit['info[0][value]'], + 'region' => 'sidebar_first', + ); + $block = BlockContent::load(1); + $url = 'admin/structure/block/add/block_content:' . $block->uuid() . '/' . $this->config('system.theme')->get('default'); + $this->drupalPostForm($url, $instance, t('Save block')); + + // Navigate to home page and check that the block is visible. It should be + // visible because it is the default revision. + $this->drupalGet(''); + $this->assertText($body); + + // Update the block. + $updated_body = 'This is the new body value'; + $edit = [ + 'body[0][value]' => $updated_body, + ]; + $this->drupalPostForm('block/' . $block->id(), $edit, t('Save and Create New Draft')); + $this->assertText(t('basic Moderated block has been updated.')); + + // Navigate to the home page and check that the block shows the updated + // content. It should show the updated content because the block's default + // revision is not a published moderation state. + $this->drupalGet(''); + $this->assertText($updated_body); + + // Publish the block so we can create a forward revision. + $this->drupalPostForm('block/' . $block->id(), [], t('Save and Publish')); + + // Create a forward revision. + $forward_revision_body = 'This is the forward revision body value'; + $edit = [ + 'body[0][value]' => $forward_revision_body, + ]; + $this->drupalPostForm('block/' . $block->id(), $edit, t('Save and Create New Draft')); + $this->assertText(t('basic Moderated block has been updated.')); + + // Navigate to home page and check that the forward revision doesn't show, + // since it should not be set as the default revision. + $this->drupalGet(''); + $this->assertText($updated_body); + + // Open the latest tab and publish the new draft. + $edit = [ + 'new_state' => 'published', + ]; + $this->drupalPostForm('block/' . $block->id() . '/latest', $edit, t('Apply')); + $this->assertText(t('The moderation state has been updated.')); + + // Navigate to home page and check that the forward revision is now the + // default revision and therefore visible. + $this->drupalGet(''); + $this->assertText($forward_revision_body); + } + +} diff --git a/core/modules/content_moderation/src/Tests/ModerationStateNodeTest.php b/core/modules/content_moderation/src/Tests/ModerationStateNodeTest.php new file mode 100644 index 0000000..8f4f1c5 --- /dev/null +++ b/core/modules/content_moderation/src/Tests/ModerationStateNodeTest.php @@ -0,0 +1,133 @@ +drupalLogin($this->adminUser); + $this->createContentTypeFromUI('Moderated content', 'moderated_content', TRUE, [ + 'draft', + 'needs_review', + 'published' + ], 'draft'); + $this->grantUserPermissionToCreateContentOfType($this->adminUser, 'moderated_content'); + } + + /** + * Tests creating and deleting content. + */ + public function testCreatingContent() { + $this->drupalPostForm('node/add/moderated_content', [ + 'title[0][value]' => 'moderated content', + ], t('Save and Create New Draft')); + $nodes = \Drupal::entityTypeManager() + ->getStorage('node') + ->loadByProperties([ + 'title' => 'moderated content', + ]); + + if (!$nodes) { + $this->fail('Test node was not saved correctly.'); + return; + } + + $node = reset($nodes); + + $path = 'node/' . $node->id() . '/edit'; + // Set up needs review revision. + $this->drupalPostForm($path, [], t('Save and Request Review')); + // Set up published revision. + $this->drupalPostForm($path, [], t('Save and Publish')); + \Drupal::entityTypeManager()->getStorage('node')->resetCache([$node->id()]); + /* @var \Drupal\node\NodeInterface $node */ + $node = \Drupal::entityTypeManager()->getStorage('node')->load($node->id()); + $this->assertTrue($node->isPublished()); + + // Verify that the state field is not shown. + $this->assertNoText('Published'); + + // Delete the node. + $this->drupalPostForm('node/' . $node->id() . '/delete', array(), t('Delete')); + $this->assertText(t('The Moderated content moderated content has been deleted.')); + } + + /** + * Tests edit form destinations. + */ + public function testFormSaveDestination() { + // Create new moderated content in draft. + $this->drupalPostForm('node/add/moderated_content', [ + 'title[0][value]' => 'Some moderated content', + 'body[0][value]' => 'First version of the content.', + ], t('Save and Create New Draft')); + + $node = $this->drupalGetNodeByTitle('Some moderated content'); + $edit_path = sprintf('node/%d/edit', $node->id()); + + // After saving, we should be at the canonical URL and viewing the first + // revision. + $this->assertUrl(Url::fromRoute('entity.node.canonical', ['node' => $node->id()])); + $this->assertText('First version of the content.'); + + // Update the draft to review; after saving, we should still be on the + // canonical URL, but viewing the second revision. + $this->drupalPostForm($edit_path, [ + 'body[0][value]' => 'Second version of the content.', + ], t('Save and Request Review')); + $this->assertUrl(Url::fromRoute('entity.node.canonical', ['node' => $node->id()])); + $this->assertText('Second version of the content.'); + + // Make a new published revision; after saving, we should be at the + // canonical URL. + $this->drupalPostForm($edit_path, [ + 'body[0][value]' => 'Third version of the content.', + ], t('Save and Publish')); + $this->assertUrl(Url::fromRoute('entity.node.canonical', ['node' => $node->id()])); + $this->assertText('Third version of the content.'); + + // Make a new forward revision; after saving, we should be on the "Latest + // version" tab. + $this->drupalPostForm($edit_path, [ + 'body[0][value]' => 'Fourth version of the content.', + ], t('Save and Create New Draft')); + $this->assertUrl(Url::fromRoute('entity.node.latest_version', ['node' => $node->id()])); + $this->assertText('Fourth version of the content.'); + } + + /** + * Tests pagers aren't broken by content_moderation. + */ + public function testPagers() { + // Create 51 nodes to force the pager. + foreach (range(1, 51) as $delta) { + Node::create([ + 'type' => 'moderated_content', + 'uid' => $this->adminUser->id(), + 'title' => 'Node ' . $delta, + 'status' => 1, + 'moderation_state' => 'published', + ])->save(); + } + $this->drupalLogin($this->adminUser); + $this->drupalGet('admin/content'); + $element = $this->cssSelect('nav.pager li.is-active a'); + $url = (string) $element[0]['href']; + $query = []; + parse_str(parse_url($url, PHP_URL_QUERY), $query); + $this->assertEqual(0, $query['page']); + } + +} diff --git a/core/modules/content_moderation/src/Tests/ModerationStateNodeTypeTest.php b/core/modules/content_moderation/src/Tests/ModerationStateNodeTypeTest.php new file mode 100644 index 0000000..807d984 --- /dev/null +++ b/core/modules/content_moderation/src/Tests/ModerationStateNodeTypeTest.php @@ -0,0 +1,71 @@ +drupalLogin($this->adminUser); + $this->createContentTypeFromUI('Not moderated', 'not_moderated'); + $this->assertText('The content type Not moderated has been added.'); + $this->grantUserPermissionToCreateContentOfType($this->adminUser, 'not_moderated'); + $this->drupalGet('node/add/not_moderated'); + $this->assertRaw('Save as unpublished'); + $this->drupalPostForm(NULL, [ + 'title[0][value]' => 'Test', + ], t('Save and publish')); + $this->assertText('Not moderated Test has been created.'); + } + + /** + * Tests enabling moderation on an existing node-type, with content. + */ + /** + * A node type without moderation state enabled. + */ + public function testEnablingOnExistingContent() { + + // Create a node type that is not moderated. + $this->drupalLogin($this->adminUser); + $this->createContentTypeFromUI('Not moderated', 'not_moderated'); + $this->grantUserPermissionToCreateContentOfType($this->adminUser, 'not_moderated'); + + // Create content. + $this->drupalGet('node/add/not_moderated'); + $this->drupalPostForm(NULL, [ + 'title[0][value]' => 'Test', + ], t('Save and publish')); + $this->assertText('Not moderated Test has been created.'); + + // Now enable moderation state. + $this->enableModerationThroughUI('not_moderated', ['draft', 'needs_review', 'published'], 'draft'); + + // And make sure it works. + $nodes = \Drupal::entityTypeManager()->getStorage('node')->loadByProperties([ + 'title' => 'Test' + ]); + if (empty($nodes)) { + $this->fail('Could not load node with title Test'); + return; + } + $node = reset($nodes); + $this->drupalGet('node/' . $node->id()); + $this->assertResponse(200); + $this->assertLinkByHref('node/' . $node->id() . '/edit'); + $this->drupalGet('node/' . $node->id() . '/edit'); + $this->assertResponse(200); + $this->assertRaw('Save and Create New Draft'); + $this->assertNoRaw('Save and publish'); + } +} diff --git a/core/modules/content_moderation/src/Tests/ModerationStateStatesTest.php b/core/modules/content_moderation/src/Tests/ModerationStateStatesTest.php new file mode 100644 index 0000000..94e7bca --- /dev/null +++ b/core/modules/content_moderation/src/Tests/ModerationStateStatesTest.php @@ -0,0 +1,75 @@ +drupalGet($path); + // No access. + $this->assertResponse(403); + } + $this->drupalLogin($this->adminUser); + foreach ($paths as $path) { + $this->drupalGet($path); + // User has access. + $this->assertResponse(200); + } + } + + /** + * Tests administration of moderation state entity. + */ + public function testStateAdministration() { + $this->drupalLogin($this->adminUser); + $this->drupalGet('admin/structure/content-moderation'); + $this->assertLink('Moderation states'); + $this->assertLink('Moderation state transitions'); + $this->clickLink('Moderation states'); + $this->assertLink('Add Moderation state'); + $this->assertText('Draft'); + // Edit the draft. + $this->clickLink('Edit', 1); + $this->assertFieldByName('label', 'Draft'); + $this->assertNoFieldChecked('edit-published'); + $this->drupalPostForm(NULL, [ + 'label' => 'Drafty', + ], t('Save')); + $this->assertText('Saved the Drafty Moderation state.'); + $this->drupalGet('admin/structure/content-moderation/states/draft'); + $this->assertFieldByName('label', 'Drafty'); + $this->drupalPostForm(NULL, [ + 'label' => 'Draft', + ], t('Save')); + $this->assertText('Saved the Draft Moderation state.'); + $this->clickLink(t('Add Moderation state')); + $this->drupalPostForm(NULL, [ + 'label' => 'Expired', + 'id' => 'expired', + ], t('Save')); + $this->assertText('Created the Expired Moderation state.'); + $this->drupalGet('admin/structure/content-moderation/states/expired'); + $this->clickLink('Delete'); + $this->assertText('Are you sure you want to delete Expired?'); + $this->drupalPostForm(NULL, [], t('Delete')); + $this->assertText('Moderation state Expired deleted'); + } + +} diff --git a/core/modules/content_moderation/src/Tests/ModerationStateTestBase.php b/core/modules/content_moderation/src/Tests/ModerationStateTestBase.php new file mode 100644 index 0000000..970c2bb --- /dev/null +++ b/core/modules/content_moderation/src/Tests/ModerationStateTestBase.php @@ -0,0 +1,149 @@ +adminUser = $this->drupalCreateUser($this->permissions); + $this->drupalPlaceBlock('local_tasks_block', ['id' => 'tabs_block']); + $this->drupalPlaceBlock('page_title_block'); + $this->drupalPlaceBlock('local_actions_block', ['id' => 'actions_block']); + } + + /** + * Creates a content-type from the UI. + * + * @param string $content_type_name + * Content type human name. + * @param string $content_type_id + * Machine name. + * @param bool $moderated + * TRUE if should be moderated + * @param string[] $allowed_states + * Array of allowed state IDs + * @param string $default_state + * Default state. + */ + protected function createContentTypeFromUI($content_type_name, $content_type_id, $moderated = FALSE, array $allowed_states = [], $default_state = NULL) { + $this->drupalGet('admin/structure/types'); + $this->clickLink('Add content type'); + $edit = [ + 'name' => $content_type_name, + 'type' => $content_type_id, + ]; + $this->drupalPostForm(NULL, $edit, t('Save content type')); + + if ($moderated) { + $this->enableModerationThroughUI($content_type_id, $allowed_states, $default_state); + } + } + + /** + * Enable moderation for a specified content type, using the UI. + * + * @param string $content_type_id + * Machine name. + * @param string[] $allowed_states + * Array of allowed state IDs. + * @param string $default_state + * Default state. + */ + protected function enableModerationThroughUI($content_type_id, array $allowed_states, $default_state) { + $this->drupalGet('admin/structure/types/manage/' . $content_type_id . '/moderation'); + $this->assertFieldByName('enable_moderation_state'); + $this->assertNoFieldChecked('edit-enable-moderation-state'); + + $edit['enable_moderation_state'] = 1; + + /** @var ModerationState $state */ + foreach (ModerationState::loadMultiple() as $id => $state) { + $key = $state->isPublishedState() ? 'allowed_moderation_states_published[' . $state->id() . ']' : 'allowed_moderation_states_unpublished[' . $state->id() . ']'; + $edit[$key] = (int)in_array($id, $allowed_states); + } + + $edit['default_moderation_state'] = $default_state; + + $this->drupalPostForm(NULL, $edit, t('Save')); + } + + + /** + * Grants given user permission to create content of given type. + * + * @param \Drupal\Core\Session\AccountInterface $account + * User to grant permission to. + * @param string $content_type_id + * Content type ID. + */ + protected function grantUserPermissionToCreateContentOfType(AccountInterface $account, $content_type_id) { + $role_ids = $account->getRoles(TRUE); + /* @var \Drupal\user\RoleInterface $role */ + $role_id = reset($role_ids); + $role = Role::load($role_id); + $role->grantPermission(sprintf('create %s content', $content_type_id)); + $role->grantPermission(sprintf('edit any %s content', $content_type_id)); + $role->grantPermission(sprintf('delete any %s content', $content_type_id)); + $role->save(); + } + +} diff --git a/core/modules/content_moderation/src/Tests/ModerationStateTransitionsTest.php b/core/modules/content_moderation/src/Tests/ModerationStateTransitionsTest.php new file mode 100644 index 0000000..8d399a0 --- /dev/null +++ b/core/modules/content_moderation/src/Tests/ModerationStateTransitionsTest.php @@ -0,0 +1,91 @@ +drupalGet($path); + // No access. + $this->assertResponse(403); + } + $this->drupalLogin($this->adminUser); + foreach ($paths as $path) { + $this->drupalGet($path); + // User has access. + $this->assertResponse(200); + } + } + + /** + * Tests administration of moderation state transition entity. + */ + public function testTransitionAdministration() { + $this->drupalLogin($this->adminUser); + + $this->drupalGet('admin/structure/content-moderation'); + $this->clickLink('Moderation state transitions'); + $this->assertLink('Add Moderation state transition'); + $this->assertText('Request Review'); + + // Edit the Draft » Needs review. + $this->drupalGet('admin/structure/content-moderation/transitions/draft_needs_review'); + $this->assertFieldByName('label', 'Request Review'); + $this->assertFieldByName('stateFrom', 'draft'); + $this->assertFieldByName('stateTo', 'needs_review'); + $this->drupalPostForm(NULL, [ + 'label' => 'Draft to Needs review', + ], t('Save')); + $this->assertText('Saved the Draft to Needs review Moderation state transition.'); + $this->drupalGet('admin/structure/content-moderation/transitions/draft_needs_review'); + $this->assertFieldByName('label', 'Draft to Needs review'); + // Now set it back. + $this->drupalPostForm(NULL, [ + 'label' => 'Request Review', + ], t('Save')); + $this->assertText('Saved the Request Review Moderation state transition.'); + + // Add a new state. + $this->drupalGet('admin/structure/content-moderation/states/add'); + $this->drupalPostForm(NULL, [ + 'label' => 'Expired', + 'id' => 'expired', + ], t('Save')); + $this->assertText('Created the Expired Moderation state.'); + + // Add a new transition. + $this->drupalGet('admin/structure/content-moderation/transitions'); + $this->clickLink(t('Add Moderation state transition')); + $this->drupalPostForm(NULL, [ + 'label' => 'Published » Expired', + 'id' => 'published_expired', + 'stateFrom' => 'published', + 'stateTo' => 'expired', + ], t('Save')); + $this->assertText('Created the Published » Expired Moderation state transition.'); + + // Delete the new transition. + $this->drupalGet('admin/structure/content-moderation/transitions/published_expired'); + $this->clickLink('Delete'); + $this->assertText('Are you sure you want to delete Published » Expired?'); + $this->drupalPostForm(NULL, [], t('Delete')); + $this->assertText('Moderation transition Published » Expired deleted'); + } + +} diff --git a/core/modules/content_moderation/src/Tests/NodeAccessTest.php b/core/modules/content_moderation/src/Tests/NodeAccessTest.php new file mode 100644 index 0000000..1454240 --- /dev/null +++ b/core/modules/content_moderation/src/Tests/NodeAccessTest.php @@ -0,0 +1,112 @@ +drupalLogin($this->adminUser); + $this->createContentTypeFromUI('Moderated content', 'moderated_content', TRUE, [ + 'draft', + 'needs_review', + 'published' + ], 'draft'); + $this->grantUserPermissionToCreateContentOfType($this->adminUser, 'moderated_content'); + } + + /** + * Verifies that a non-admin user can still access the appropriate pages. + */ + public function testPageAccess() { + $this->drupalLogin($this->adminUser); + + // Create a node to test with. + $this->drupalPostForm('node/add/moderated_content', [ + 'title[0][value]' => 'moderated content', + ], t('Save and Create New Draft')); + $nodes = \Drupal::entityTypeManager() + ->getStorage('node') + ->loadByProperties([ + 'title' => 'moderated content', + ]); + + if (!$nodes) { + $this->fail('Test node was not saved correctly.'); + return; + } + + /** @var NodeInterface $node */ + $node = reset($nodes); + + $view_path = 'node/' . $node->id(); + $edit_path = 'node/' . $node->id() . '/edit'; + $latest_path = 'node/' . $node->id() . '/latest'; + + // Publish the node. + $this->drupalPostForm($edit_path, [], t('Save and Request Review')); + $this->drupalPostForm($edit_path, [], t('Save and Publish')); + + // Ensure access works correctly for anonymous users. + $this->drupalLogout(); + + $this->drupalGet($edit_path); + $this->assertResponse(403); + + $this->drupalGet($latest_path); + $this->assertResponse(403); + $this->drupalGet($view_path); + $this->assertResponse(200); + + // Create a forward revision for the 'Latest revision' tab. + $this->drupalLogin($this->adminUser); + $this->drupalPostForm($edit_path, [ + 'title[0][value]' => 'moderated content revised', + ], t('Save and Create New Draft')); + + // Now make a new user and verify that the new user's access is correct. + $user = $this->createUser([ + 'use draft_draft transition', + 'use draft_needs_review transition', + 'use published_draft transition', + 'use needs_review_published transition', + 'view latest version', + 'view any unpublished content', + ]); + $this->drupalLogin($user); + + $this->drupalGet($edit_path); + $this->assertResponse(403); + + $this->drupalGet($latest_path); + $this->assertResponse(200); + $this->drupalGet($view_path); + $this->assertResponse(200); + + // Now make another user, who should not be able to see forward revisions. + $user = $this->createUser([ + 'use draft_needs_review transition', + 'use published_draft transition', + 'use needs_review_published transition', + ]); + $this->drupalLogin($user); + + $this->drupalGet($edit_path); + $this->assertResponse(403); + + $this->drupalGet($latest_path); + $this->assertResponse(403); + $this->drupalGet($view_path); + $this->assertResponse(200); + } +} diff --git a/core/modules/content_moderation/templates/entity-moderation-form.html.twig b/core/modules/content_moderation/templates/entity-moderation-form.html.twig new file mode 100644 index 0000000..403f5f0 --- /dev/null +++ b/core/modules/content_moderation/templates/entity-moderation-form.html.twig @@ -0,0 +1,8 @@ +{{ attach_library('content_moderation/entity-moderation-form') }} + +{{ form|without('current', 'new_state', 'revision_log', 'submit') }} diff --git a/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.latest.yml b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.latest.yml new file mode 100644 index 0000000..4be38e0 --- /dev/null +++ b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.latest.yml @@ -0,0 +1,399 @@ +langcode: en +status: true +dependencies: + config: + - system.menu.main + module: + - user + - content_moderation +id: latest +label: Latest +module: views +description: '' +tag: '' +base_table: node_field_revision +base_field: vid +core: 8.x +display: + default: + display_plugin: default + id: default + display_title: Master + position: 0 + display_options: + access: + type: perm + options: + perm: 'view all revisions' + cache: + type: tag + options: { } + query: + type: views_query + options: + disable_sql_rewrite: false + distinct: false + replica: false + query_comment: '' + query_tags: { } + exposed_form: + type: basic + options: + submit_button: Apply + reset_button: false + reset_button_label: Reset + exposed_sorts_label: 'Sort by' + expose_sort_order: true + sort_asc_label: Asc + sort_desc_label: Desc + pager: + type: full + options: + items_per_page: 10 + offset: 0 + id: 0 + total_pages: null + expose: + items_per_page: false + items_per_page_label: 'Items per page' + items_per_page_options: '5, 10, 25, 50' + items_per_page_options_all: false + items_per_page_options_all_label: '- All -' + offset: false + offset_label: Offset + tags: + previous: '‹ Previous' + next: 'Next ›' + first: '« First' + last: 'Last »' + quantity: 9 + style: + type: table + row: + type: fields + fields: + nid: + id: nid + table: node_field_revision + field: nid + relationship: none + group_type: group + admin_label: '' + label: 'Node ID' + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: true + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + click_sort_column: value + type: number_integer + settings: + thousand_separator: '' + prefix_suffix: true + group_column: value + group_columns: { } + group_rows: true + delta_limit: 0 + delta_offset: 0 + delta_reversed: false + delta_first_last: false + multi_type: separator + separator: ', ' + field_api_classes: false + entity_type: node + entity_field: nid + plugin_id: field + vid: + id: vid + table: node_field_revision + field: vid + relationship: none + group_type: group + admin_label: '' + label: 'Revision ID' + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: true + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + click_sort_column: value + type: number_integer + settings: + thousand_separator: '' + prefix_suffix: true + group_column: value + group_columns: { } + group_rows: true + delta_limit: 0 + delta_offset: 0 + delta_reversed: false + delta_first_last: false + multi_type: separator + separator: ', ' + field_api_classes: false + entity_type: node + entity_field: vid + plugin_id: field + title: + id: title + table: node_field_revision + field: title + entity_type: node + entity_field: title + alter: + alter_text: false + make_link: false + absolute: false + trim: false + word_boundary: false + ellipsis: false + strip_tags: false + html: false + hide_empty: false + empty_zero: false + settings: + link_to_entity: false + plugin_id: field + relationship: none + group_type: group + admin_label: '' + label: Title + exclude: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: true + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_alter_empty: true + click_sort_column: value + type: string + group_column: value + group_columns: { } + group_rows: true + delta_limit: 0 + delta_offset: 0 + delta_reversed: false + delta_first_last: false + multi_type: separator + separator: ', ' + field_api_classes: false + moderation_state: + id: moderation_state + table: node_field_revision + field: moderation_state + relationship: none + group_type: group + admin_label: '' + label: 'Moderation state' + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: true + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + click_sort_column: target_id + type: entity_reference_label + settings: + link: true + group_column: target_id + group_columns: { } + group_rows: true + delta_limit: 0 + delta_offset: 0 + delta_reversed: false + delta_first_last: false + multi_type: separator + separator: ', ' + field_api_classes: false + entity_type: node + entity_field: moderation_state + plugin_id: field + filters: + latest_revision: + id: latest_revision + table: node_revision + field: latest_revision + relationship: none + group_type: group + admin_label: '' + operator: '=' + value: '' + group: 1 + exposed: false + expose: + operator_id: '' + label: '' + description: '' + use_operator: false + operator: '' + identifier: '' + required: false + remember: false + multiple: false + remember_roles: + authenticated: authenticated + is_grouped: false + group_info: + label: '' + description: '' + identifier: '' + optional: true + widget: select + multiple: false + remember: false + default_group: All + default_group_multiple: { } + group_items: { } + entity_type: node + plugin_id: latest_revision + sorts: { } + title: Latest + header: { } + footer: { } + empty: { } + relationships: { } + arguments: { } + display_extenders: { } + cache_metadata: + max-age: 0 + contexts: + - 'languages:language_content' + - 'languages:language_interface' + - url.query_args + - 'user.node_grants:view' + - user.permissions + tags: { } + page_1: + display_plugin: page + id: page_1 + display_title: Page + position: 1 + display_options: + display_extenders: { } + path: latest + menu: + type: normal + title: Drafts + description: '' + expanded: false + parent: '' + weight: 0 + context: '0' + menu_name: main + cache_metadata: + max-age: 0 + contexts: + - 'languages:language_content' + - 'languages:language_interface' + - url.query_args + - 'user.node_grants:view' + - user.permissions + tags: { } diff --git a/core/modules/content_moderation/tests/modules/content_moderation_test_views/content_moderation_test_views.info.yml b/core/modules/content_moderation/tests/modules/content_moderation_test_views/content_moderation_test_views.info.yml new file mode 100644 index 0000000..4364b33 --- /dev/null +++ b/core/modules/content_moderation/tests/modules/content_moderation_test_views/content_moderation_test_views.info.yml @@ -0,0 +1,8 @@ +name: 'Content moderation test views' +type: module +description: 'Provides default views for views Content moderation tests.' +package: Testing +version: VERSION +core: 8.x +dependencies: + - content_moderation diff --git a/core/modules/content_moderation/tests/src/Functional/LatestRevisionViewsFilterTest.php b/core/modules/content_moderation/tests/src/Functional/LatestRevisionViewsFilterTest.php new file mode 100644 index 0000000..e3a739a --- /dev/null +++ b/core/modules/content_moderation/tests/src/Functional/LatestRevisionViewsFilterTest.php @@ -0,0 +1,134 @@ +createNodeType('Test', 'test'); + + $permissions = [ + 'access content', + 'view all revisions', + ]; + $editor1 = $this->drupalCreateUser($permissions); + + $this->drupalLogin($editor1); + + // Make a pre-moderation node. + /** @var Node $node_0 */ + $node_0 = Node::create([ + 'type' => 'test', + 'title' => 'Node 0 - Rev 1', + 'uid' => $editor1->id(), + ]); + $node_0->save(); + + // Now enable moderation for subsequent nodes. + + $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE); + $node_type->save(); + + // Make a node that is only ever in Draft. + + /** @var Node $node_1 */ + $node_1 = Node::create([ + 'type' => 'test', + 'title' => 'Node 1 - Rev 1', + 'uid' => $editor1->id(), + ]); + $node_1->moderation_state->target_id = 'draft'; + $node_1->save(); + + // Make a node that is in Draft, then Published. + + /** @var Node $node_2 */ + $node_2 = Node::create([ + 'type' => 'test', + 'title' => 'Node 2 - Rev 1', + 'uid' => $editor1->id(), + ]); + $node_2->moderation_state->target_id = 'draft'; + $node_2->save(); + + $node_2->setTitle('Node 2 - Rev 2'); + $node_2->moderation_state->target_id = 'published'; + $node_2->save(); + + // Make a node that is in Draft, then Published, then Draft. + + /** @var Node $node_3 */ + $node_3 = Node::create([ + 'type' => 'test', + 'title' => 'Node 3 - Rev 1', + 'uid' => $editor1->id(), + ]); + $node_3->moderation_state->target_id = 'draft'; + $node_3->save(); + + $node_3->setTitle('Node 3 - Rev 2'); + $node_3->moderation_state->target_id = 'published'; + $node_3->save(); + + $node_3->setTitle('Node 3 - Rev 3'); + $node_3->moderation_state->target_id = 'draft'; + $node_3->save(); + + + // Now show the View, and confirm that only the correct titles are showing. + + $this->drupalGet('/latest'); + $page = $this->getSession()->getPage(); + $this->assertEquals(200, $this->getSession()->getStatusCode()); + $this->assertTrue($page->hasContent('Node 1 - Rev 1')); + $this->assertTrue($page->hasContent('Node 2 - Rev 2')); + $this->assertTrue($page->hasContent('Node 3 - Rev 3')); + $this->assertFalse($page->hasContent('Node 2 - Rev 1')); + $this->assertFalse($page->hasContent('Node 3 - Rev 1')); + $this->assertFalse($page->hasContent('Node 3 - Rev 2')); + $this->assertFalse($page->hasContent('Node 0 - Rev 1')); + } + + /** + * Creates a new node type. + * + * @param string $label + * The human-readable label of the type to create. + * @param string $machine_name + * The machine name of the type to create. + * + * @return NodeType + * The node type just created. + */ + protected function createNodeType($label, $machine_name) { + /** @var NodeType $node_type */ + $node_type = NodeType::create([ + 'type' => $machine_name, + 'label' => $label, + ]); + $node_type->save(); + + return $node_type; + } + +} diff --git a/core/modules/content_moderation/tests/src/Kernel/ContentModerationSchemaTest.php b/core/modules/content_moderation/tests/src/Kernel/ContentModerationSchemaTest.php new file mode 100644 index 0000000..ac2b028 --- /dev/null +++ b/core/modules/content_moderation/tests/src/Kernel/ContentModerationSchemaTest.php @@ -0,0 +1,83 @@ +installConfig(['content_moderation']); + $typed_config = \Drupal::service('config.typed'); + $moderation_states = ModerationState::loadMultiple(); + foreach ($moderation_states as $moderation_state) { + $this->assertConfigSchema($typed_config, $moderation_state->getEntityType()->getConfigPrefix(). '.' . $moderation_state->id(), $moderation_state->toArray()); + } + $moderation_state_transitions = ModerationStateTransition::loadMultiple(); + foreach ($moderation_state_transitions as $moderation_state_transition) { + $this->assertConfigSchema($typed_config, $moderation_state_transition->getEntityType()->getConfigPrefix(). '.' . $moderation_state_transition->id(), $moderation_state_transition->toArray()); + } + + } + + /** + * Tests content moderation third party schema for node types. + */ + public function testContentModerationNodeTypeConfig() { + $this->installEntitySchema('node'); + $this->installEntitySchema('user'); + $this->installConfig(['content_moderation']); + $typed_config = \Drupal::service('config.typed'); + $moderation_states = ModerationState::loadMultiple(); + $node_type = NodeType::create([ + 'type' => 'example', + ]); + $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE); + $node_type->setThirdPartySetting('content_moderation', 'allowed_moderation_states', array_keys($moderation_states)); + $node_type->setThirdPartySetting('content_moderation', 'default_moderation_state', ''); + $node_type->save(); + $this->assertConfigSchema($typed_config, $node_type->getEntityType()->getConfigPrefix(). '.' . $node_type->id(), $node_type->toArray()); + } + + /** + * Tests content moderation third party schema for block content types. + */ + public function testContentModerationBlockContentTypeConfig() { + $this->installEntitySchema('block_content'); + $this->installEntitySchema('user'); + $this->installConfig(['content_moderation']); + $typed_config = \Drupal::service('config.typed'); + $moderation_states = ModerationState::loadMultiple(); + $block_content_type = BlockContentType::create([ + 'id' => 'basic', + 'label' => 'basic', + 'revision' => TRUE, + ]); + $block_content_type->setThirdPartySetting('content_moderation', 'enabled', TRUE); + $block_content_type->setThirdPartySetting('content_moderation', 'allowed_moderation_states', array_keys($moderation_states)); + $block_content_type->setThirdPartySetting('content_moderation', 'default_moderation_state', ''); + $block_content_type->save(); + $this->assertConfigSchema($typed_config, $block_content_type->getEntityType()->getConfigPrefix(). '.' . $block_content_type->id(), $block_content_type->toArray()); + } + +} diff --git a/core/modules/content_moderation/tests/src/Kernel/EntityOperationsTest.php b/core/modules/content_moderation/tests/src/Kernel/EntityOperationsTest.php new file mode 100644 index 0000000..60def39 --- /dev/null +++ b/core/modules/content_moderation/tests/src/Kernel/EntityOperationsTest.php @@ -0,0 +1,192 @@ +installEntitySchema('node'); + $this->installSchema('node', 'node_access'); + $this->installEntitySchema('user'); + $this->installConfig('content_moderation'); + + $this->createNodeType(); + } + + /** + * Creates a page node type to test with, ensuring that it's moderatable. + */ + protected function createNodeType() { + $node_type = NodeType::create([ + 'type' => 'page', + 'label' => 'Page', + ]); + $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE); + $node_type->save(); + } + + /** + * Verifies that the process of saving forward-revisions works as expected. + */ + public function testForwardRevisions() { + // Create a new node in draft. + $page = Node::create([ + 'type' => 'page', + 'title' => 'A', + ]); + $page->moderation_state->target_id = 'draft'; + $page->save(); + + $id = $page->id(); + + // Verify the entity saved correctly, and that the presence of forward + // revisions doesn't affect the default node load. + /** @var Node $page */ + $page = Node::load($id); + $this->assertEquals('A', $page->getTitle()); + $this->assertTrue($page->isDefaultRevision()); + $this->assertFalse($page->isPublished()); + + // Moderate the entity to published. + $page->setTitle('B'); + $page->moderation_state->target_id = 'published'; + $page->save(); + + // Verify the entity is now published and public. + $page = Node::load($id); + $this->assertEquals('B', $page->getTitle()); + $this->assertTrue($page->isDefaultRevision()); + $this->assertTrue($page->isPublished()); + + // Make a new forward-revision in Draft. + $page->setTitle('C'); + $page->moderation_state->target_id = 'draft'; + $page->save(); + + // Verify normal loads return the still-default previous version. + $page = Node::load($id); + $this->assertEquals('B', $page->getTitle()); + + // Verify we can load the forward revision, even if the mechanism is kind + // of gross. Note: revisionIds() is only available on NodeStorageInterface, + // so this won't work for non-nodes. We'd need to use entity queries. This + // is a core bug that should get fixed. + $storage = \Drupal::entityTypeManager()->getStorage('node'); + $revision_ids = $storage->revisionIds($page); + sort($revision_ids); + $latest = end($revision_ids); + $page = $storage->loadRevision($latest); + $this->assertEquals('C', $page->getTitle()); + + $page->setTitle('D'); + $page->moderation_state->target_id = 'published'; + $page->save(); + + // Verify normal loads return the still-default previous version. + $page = Node::load($id); + $this->assertEquals('D', $page->getTitle()); + $this->assertTrue($page->isDefaultRevision()); + $this->assertTrue($page->isPublished()); + + // Now check that we can immediately add a new published revision over it. + $page->setTitle('E'); + $page->moderation_state->target_id = 'published'; + $page->save(); + + $page = Node::load($id); + $this->assertEquals('E', $page->getTitle()); + $this->assertTrue($page->isDefaultRevision()); + $this->assertTrue($page->isPublished()); + } + + /** + * Verifies that a newly-created node can go straight to published. + */ + public function testPublishedCreation() { + // Create a new node in draft. + $page = Node::create([ + 'type' => 'page', + 'title' => 'A', + ]); + $page->moderation_state->target_id = 'published'; + $page->save(); + + $id = $page->id(); + + // Verify the entity saved correctly. + /** @var Node $page */ + $page = Node::load($id); + $this->assertEquals('A', $page->getTitle()); + $this->assertTrue($page->isDefaultRevision()); + $this->assertTrue($page->isPublished()); + } + + /** + * Verifies that an unpublished state may be made the default revision. + */ + public function testArchive() { + $published_id = $this->randomMachineName(); + $published_state = ModerationState::create([ + 'id' => $published_id, + 'label' => $this->randomString(), + 'published' => TRUE, + 'default_revision' => TRUE, + ]); + $published_state->save(); + + $archived_id = $this->randomMachineName(); + $archived_state = ModerationState::create([ + 'id' => $archived_id, + 'label' => $this->randomString(), + 'published' => FALSE, + 'default_revision' => TRUE, + ]); + $archived_state->save(); + + $page = Node::create([ + 'type' => 'page', + 'title' => $this->randomString(), + ]); + $page->moderation_state->target_id = $published_id; + $page->save(); + + $id = $page->id(); + + // The newly-created page should already be published. + $page = Node::load($id); + $this->assertTrue($page->isPublished()); + + // When the page is moderated to the archived state, then the latest + // revision should be the default revision, and it should be unpublished. + $page->moderation_state->target_id = $archived_id; + $page->save(); + $new_revision_id = $page->getRevisionId(); + + $storage = \Drupal::entityTypeManager()->getStorage('node'); + $new_revision = $storage->loadRevision($new_revision_id); + $this->assertFalse($new_revision->isPublished()); + $this->assertTrue($new_revision->isDefaultRevision()); + } + +} diff --git a/core/modules/content_moderation/tests/src/Kernel/EntityRevisionConverterTest.php b/core/modules/content_moderation/tests/src/Kernel/EntityRevisionConverterTest.php new file mode 100644 index 0000000..d32595b --- /dev/null +++ b/core/modules/content_moderation/tests/src/Kernel/EntityRevisionConverterTest.php @@ -0,0 +1,82 @@ +installEntitySchema('entity_test'); + $this->installEntitySchema('node'); + $this->installEntitySchema('user'); + $this->installSchema('system', 'router'); + $this->installSchema('system', 'sequences'); + $this->installSchema('node', 'node_access'); + \Drupal::service('router.builder')->rebuild(); + } + + public function testConvertNonRevisionableEntityType() { + $entity_test = EntityTest::create([ + 'name' => 'test', + ]); + + $entity_test->save(); + + /** @var \Symfony\Component\Routing\RouterInterface $router */ + $router = \Drupal::service('router.no_access_checks'); + $result = $router->match('/entity_test/' . $entity_test->id()); + + $this->assertInstanceOf(EntityTest::class, $result['entity_test']); + $this->assertEquals($entity_test->getRevisionId(), $result['entity_test']->getRevisionId()); + } + + public function testConvertWithRevisionableEntityType() { + $node_type = NodeType::create([ + 'type' => 'article', + ]); + $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE); + $node_type->save(); + + $revision_ids = []; + $node = Node::create([ + 'title' => 'test', + 'type' => 'article' + ]); + $node->save(); + + $revision_ids[] = $node->getRevisionId(); + + $node->setNewRevision(TRUE); + $node->save(); + $revision_ids[] = $node->getRevisionId(); + + $node->setNewRevision(TRUE); + $node->isDefaultRevision(FALSE); + $node->save(); + $revision_ids[] = $node->getRevisionId(); + + /** @var \Symfony\Component\Routing\RouterInterface $router */ + $router = \Drupal::service('router.no_access_checks'); + $result = $router->match('/node/' . $node->id() . '/edit'); + + $this->assertInstanceOf(Node::class, $result['node']); + $this->assertEquals($revision_ids[2], $result['node']->getRevisionId()); + $this->assertFalse($result['node']->isDefaultRevision()); + } + +} diff --git a/core/modules/content_moderation/tests/src/Kernel/EntityStateChangeValidationTest.php b/core/modules/content_moderation/tests/src/Kernel/EntityStateChangeValidationTest.php new file mode 100644 index 0000000..17c6e50 --- /dev/null +++ b/core/modules/content_moderation/tests/src/Kernel/EntityStateChangeValidationTest.php @@ -0,0 +1,162 @@ +installSchema('node', 'node_access'); + $this->installEntitySchema('node'); + $this->installEntitySchema('user'); + $this->installConfig('content_moderation'); + } + + /** + * Test valid transitions. + * + * @covers ::validate + */ + public function testValidTransition() { + $node_type = NodeType::create([ + 'type' => 'example', + ]); + $node_type->save(); + $node = Node::create([ + 'type' => 'example', + 'title' => 'Test title', + 'moderation_state' => 'draft', + ]); + $node->save(); + + $node->moderation_state->target_id = 'needs_review'; + $this->assertCount(0, $node->validate()); + } + + /** + * Test invalid transitions. + * + * @covers ::validate + */ + public function testInvalidTransition() { + $node_type = NodeType::create([ + 'type' => 'example', + ]); + $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE); + $node_type->save(); + $node = Node::create([ + 'type' => 'example', + 'title' => 'Test title', + 'moderation_state' => 'draft', + ]); + $node->save(); + + $node->moderation_state->target_id = 'archived'; + $violations = $node->validate(); + $this->assertCount(1, $violations); + + $this->assertEquals('Invalid state transition from Draft to Archived', $violations->get(0)->getMessage()); + } + + /** + * Verifies that content without prior moderation information can be moderated. + */ + public function testLegacyContent() { + $node_type = NodeType::create([ + 'type' => 'example', + ]); + $node_type->save(); + /** @var NodeInterface $node */ + $node = Node::create([ + 'type' => 'example', + 'title' => 'Test title', + ]); + $node->save(); + + $nid = $node->id(); + + // Enable moderation for our node type. + /** @var NodeType $node_type */ + $node_type = NodeType::load('example'); + $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE); + $node_type->setThirdPartySetting('content_moderation', 'allowed_moderation_states', ['draft', 'needs_review', 'published']); + $node_type->setThirdPartySetting('content_moderation', 'default_moderation_state', 'draft'); + $node_type->save(); + + $node = Node::load($nid); + + // Having no previous state should not break validation. + $violations = $node->validate(); + + $this->assertCount(0, $violations); + + // Having no previous state should not break saving the node. + $node->setTitle('New'); + $node->save(); + } + + /** + * Verifies that content without prior moderation information can be translated. + */ + public function testLegacyMultilingualContent() { + // Enable French + ConfigurableLanguage::createFromLangcode('fr')->save(); + + $node_type = NodeType::create([ + 'type' => 'example', + ]); + $node_type->save(); + /** @var NodeInterface $node */ + $node = Node::create([ + 'type' => 'example', + 'title' => 'Test title', + 'langcode' => 'en', + ]); + $node->save(); + + $nid = $node->id(); + + $node = Node::load($nid); + + // Creating a translation shouldn't break, even though there's no previous + // moderated revision for the new language. + $node_fr = $node->addTranslation('fr'); + $node_fr->setTitle('Francais'); + $node_fr->save(); + + // Enable moderation for our node type. + /** @var NodeType $node_type */ + $node_type = NodeType::load('example'); + $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE); + $node_type->setThirdPartySetting('content_moderation', 'allowed_moderation_states', ['draft', 'needs_review', 'published']); + $node_type->setThirdPartySetting('content_moderation', 'default_moderation_state', 'draft'); + $node_type->save(); + + // Reload the French version of the node. + $node = Node::load($nid); + $node_fr = $node->getTranslation('fr'); + + /** @var NodeInterface $node_fr */ + $node_fr->setTitle('Nouveau'); + $node_fr->save(); + } +} diff --git a/core/modules/content_moderation/tests/src/Kernel/ModerationStateEntityTest.php b/core/modules/content_moderation/tests/src/Kernel/ModerationStateEntityTest.php new file mode 100644 index 0000000..c9d639c --- /dev/null +++ b/core/modules/content_moderation/tests/src/Kernel/ModerationStateEntityTest.php @@ -0,0 +1,71 @@ +installEntitySchema('moderation_state'); + } + + /** + * Verify moderation state methods based on entity properties. + * + * @covers ::isPublishedState + * @covers ::isDefaultRevisionState + * + * @dataProvider moderationStateProvider + */ + public function testModerationStateProperties($published, $default_revision, $is_published, $is_default) { + $moderation_state_id = $this->randomMachineName(); + $moderation_state = ModerationState::create([ + 'id' => $moderation_state_id, + 'label' => $this->randomString(), + 'published' => $published, + 'default_revision' => $default_revision, + ]); + $moderation_state->save(); + + $moderation_state = ModerationState::load($moderation_state_id); + $this->assertEquals($is_published, $moderation_state->isPublishedState()); + $this->assertEquals($is_default, $moderation_state->isDefaultRevisionState()); + } + + /** + * Data provider for ::testModerationStateProperties. + */ + public function moderationStateProvider() { + return [ + // Draft, Needs review; should not touch the default revision. + [FALSE, FALSE, FALSE, FALSE], + // Published; this state should update and publish the default revision. + [TRUE, TRUE, TRUE, TRUE], + // Archive; this state should update but not publish the default revision. + [FALSE, TRUE, FALSE, TRUE], + // We try to prevent creating this state via the UI, but when a moderation + // state is a published state, it should also become the default revision. + [TRUE, FALSE, TRUE, TRUE], + ]; + } + +} diff --git a/core/modules/content_moderation/tests/src/Kernel/ModerationStateFieldTest.php b/core/modules/content_moderation/tests/src/Kernel/ModerationStateFieldTest.php new file mode 100644 index 0000000..ca022ff --- /dev/null +++ b/core/modules/content_moderation/tests/src/Kernel/ModerationStateFieldTest.php @@ -0,0 +1,38 @@ +installEntitySchema('node'); + } + + /** + * {@inheritdoc} + * + * Covers moderation_state_install(). + */ + public function testModerationStateFieldIsCreated() { + // There should be no updates as moderation_state_install() should have + // applied the new field. + $this->assertTrue(empty($this->container->get('entity.definition_update_manager')->needsUpdates()['node'])); + $this->assertTrue(!empty($this->container->get('entity_field.manager')->getFieldStorageDefinitions('node')['moderation_state'])); + } + +} diff --git a/core/modules/content_moderation/tests/src/Unit/ContentPreprocessTest.php b/core/modules/content_moderation/tests/src/Unit/ContentPreprocessTest.php new file mode 100644 index 0000000..bdc31e3 --- /dev/null +++ b/core/modules/content_moderation/tests/src/Unit/ContentPreprocessTest.php @@ -0,0 +1,64 @@ +setupCurrentRouteMatch($route_name, $route_nid)); + $node = $this->setupNode($check_nid); + $this->assertEquals($result, $content_preprocess->isLatestVersionPage($node), $message); + } + + public function routeNodeProvider() { + return [ + ['entity.node.cannonical', 1, 1, FALSE, 'Not on the latest version tab route.'], + ['entity.node.latest_version', 1, 1, TRUE, 'On the latest version tab route, with the route node.'], + ['entity.node.latest_version', 1, 2, FALSE, 'On the latest version tab route, with a different node.'], + ]; + } + + /** + * Mock the current route matching object. + * + * @param string $route + * @param int $nid + * + * @return CurrentRouteMatch + */ + protected function setupCurrentRouteMatch($routeName, $nid) { + $route_match = $this->prophesize(CurrentRouteMatch::class); + $route_match->getRouteName()->willReturn($routeName); + $route_match->getParameter('node')->willReturn($this->setupNode($nid)); + + return $route_match->reveal(); + } + + /** + * Mock a node object. + * + * @param int $nid + * @return Node + */ + protected function setupNode($nid) { + $node = $this->prophesize(Node::class); + $node->id()->willReturn($nid); + + return $node->reveal(); + } +} diff --git a/core/modules/content_moderation/tests/src/Unit/LatestRevisionCheckTest.php b/core/modules/content_moderation/tests/src/Unit/LatestRevisionCheckTest.php new file mode 100644 index 0000000..c9eda19 --- /dev/null +++ b/core/modules/content_moderation/tests/src/Unit/LatestRevisionCheckTest.php @@ -0,0 +1,79 @@ +prophesize($entity_class); + $entity->getCacheContexts()->willReturn([]); + $entity->getCacheTags()->willReturn([]); + $entity->getCacheMaxAge()->willReturn(0); + + /** @var ModerationInformationInterface $mod_info */ + $mod_info = $this->prophesize(ModerationInformation::class); + $mod_info->hasForwardRevision($entity->reveal())->willReturn($has_forward); + + $route = $this->prophesize(Route::class); + + $route->getOption('_content_moderation_entity_type')->willReturn($entity_type); + + $route_match = $this->prophesize(RouteMatch::class); + $route_match->getParameter($entity_type)->willReturn($entity->reveal()); + + $lrc = new LatestRevisionCheck($mod_info->reveal()); + + /** @var AccessResult $result */ + $result = $lrc->access($route->reveal(), $route_match->reveal()); + + $this->assertInstanceOf($result_class, $result); + + } + + /** + * Data provider for testLastAccessPermissions(). + * + * @return array + */ + public function accessSituationProvider() { + return [ + [Node::class, 'node', TRUE, AccessResultAllowed::class], + [Node::class, 'node', FALSE, AccessResultForbidden::class], + [BlockContent::class, 'block_content', TRUE, AccessResultAllowed::class], + [BlockContent::class, 'block_content', FALSE, AccessResultForbidden::class], + ]; + } +} diff --git a/core/modules/content_moderation/tests/src/Unit/ModerationInformationTest.php b/core/modules/content_moderation/tests/src/Unit/ModerationInformationTest.php new file mode 100644 index 0000000..f913a36 --- /dev/null +++ b/core/modules/content_moderation/tests/src/Unit/ModerationInformationTest.php @@ -0,0 +1,140 @@ +prophesize(AccountInterface::class)->reveal(); + } + + /** + * Returns a mock Entity Type Manager. + * + * @param \Drupal\Core\Entity\EntityStorageInterface $entity_bundle_storage + * + * @return EntityTypeManagerInterface + */ + protected function getEntityTypeManager(EntityStorageInterface $entity_bundle_storage) { + $entity_type_manager = $this->prophesize(EntityTypeManagerInterface::class); + $entity_type_manager->getStorage('entity_test_bundle')->willReturn($entity_bundle_storage); + return $entity_type_manager->reveal(); + } + + public function setupModerationEntityManager($status) { + $bundle = $this->prophesize(ConfigEntityInterface::class); + $bundle->getThirdPartySetting('content_moderation', 'enabled', FALSE)->willReturn($status); + + $entity_storage = $this->prophesize(EntityStorageInterface::class); + $entity_storage->load('test_bundle')->willReturn($bundle->reveal()); + + return $this->getEntityTypeManager($entity_storage->reveal()); + } + + /** + * @dataProvider providerBoolean + * @covers ::isModeratableEntity + */ + public function testIsModeratableEntity($status) { + $moderation_information = new ModerationInformation($this->setupModerationEntityManager($status), $this->getUser()); + + $entity_type = new ContentEntityType([ + 'id' => 'test_entity_type', + 'bundle_entity_type' => 'entity_test_bundle', + ]); + $entity = $this->prophesize(ContentEntityInterface::class); + $entity->getEntityType()->willReturn($entity_type); + $entity->bundle()->willReturn('test_bundle'); + + $this->assertEquals($status, $moderation_information->isModeratableEntity($entity->reveal())); + } + + /** + * @covers ::isModeratableEntity + */ + public function testIsModeratableEntityForNonBundleEntityType() { + $entity_type = new ContentEntityType([ + 'id' => 'test_entity_type', + ]); + $entity = $this->prophesize(ContentEntityInterface::class); + $entity->getEntityType()->willReturn($entity_type); + $entity->bundle()->willReturn('test_entity_type'); + + $entity_storage = $this->prophesize(EntityStorageInterface::class); + $entity_type_manager = $this->getEntityTypeManager($entity_storage->reveal()); + $moderation_information = new ModerationInformation($entity_type_manager, $this->getUser()); + + $this->assertEquals(FALSE, $moderation_information->isModeratableEntity($entity->reveal())); + } + + /** + * @dataProvider providerBoolean + * @covers ::isModeratableBundle + */ + public function testIsModeratableBundle($status) { + $entity_type = new ContentEntityType([ + 'id' => 'test_entity_type', + 'bundle_entity_type' => 'entity_test_bundle', + ]); + + $moderation_information = new ModerationInformation($this->setupModerationEntityManager($status), $this->getUser()); + + $this->assertEquals($status, $moderation_information->isModeratableBundle($entity_type, 'test_bundle')); + } + + /** + * @dataProvider providerBoolean + * @covers ::isModeratedEntityForm + */ + public function testIsModeratedEntityForm($status) { + $entity_type = new ContentEntityType([ + 'id' => 'test_entity_type', + 'bundle_entity_type' => 'entity_test_bundle', + ]); + + $entity = $this->prophesize(ContentEntityInterface::class); + $entity->getEntityType()->willReturn($entity_type); + $entity->bundle()->willReturn('test_bundle'); + + $form = $this->prophesize(ContentEntityFormInterface::class); + $form->getEntity()->willReturn($entity); + + $moderation_information = new ModerationInformation($this->setupModerationEntityManager($status), $this->getUser()); + + $this->assertEquals($status, $moderation_information->isModeratedEntityForm($form->reveal())); + } + + public function testIsModeratedEntityFormWithNonContentEntityForm() { + $form = $this->prophesize(EntityFormInterface::class); + $moderation_information = new ModerationInformation($this->setupModerationEntityManager(TRUE), $this->getUser()); + + $this->assertFalse($moderation_information->isModeratedEntityForm($form->reveal())); + } + + public function providerBoolean() { + return [ + [FALSE], + [TRUE], + ]; + } + +} diff --git a/core/modules/content_moderation/tests/src/Unit/StateTransitionValidationTest.php b/core/modules/content_moderation/tests/src/Unit/StateTransitionValidationTest.php new file mode 100644 index 0000000..366db21 --- /dev/null +++ b/core/modules/content_moderation/tests/src/Unit/StateTransitionValidationTest.php @@ -0,0 +1,252 @@ +prophesize(EntityStorageInterface::class); + + $list = $this->setupTransitionEntityList(); + $entity_storage->loadMultiple()->willReturn($list); + $entity_storage->loadMultiple(Argument::type('array'))->will(function ($args) use ($list) { + $keys = $args[0]; + if (empty($keys)) { + return $list; + } + + $return = array_map(function($key) use ($list) { + return $list[$key]; + }, $keys); + + return $return; + }); + return $entity_storage->reveal(); + } + + /** + * Builds an array of mocked Transition objects. + * + * @return ModerationStateTransitionInterface[] + */ + protected function setupTransitionEntityList() { + $transition = $this->prophesize(ModerationStateTransitionInterface::class); + $transition->id()->willReturn('draft__needs_review'); + $transition->getFromState()->willReturn('draft'); + $transition->getToState()->willReturn('needs_review'); + $list[$transition->reveal()->id()] = $transition->reveal(); + + $transition = $this->prophesize(ModerationStateTransitionInterface::class); + $transition->id()->willReturn('needs_review__staging'); + $transition->getFromState()->willReturn('needs_review'); + $transition->getToState()->willReturn('staging'); + $list[$transition->reveal()->id()] = $transition->reveal(); + + $transition = $this->prophesize(ModerationStateTransitionInterface::class); + $transition->id()->willReturn('staging__published'); + $transition->getFromState()->willReturn('staging'); + $transition->getToState()->willReturn('published'); + $list[$transition->reveal()->id()] = $transition->reveal(); + + $transition = $this->prophesize(ModerationStateTransitionInterface::class); + $transition->id()->willReturn('needs_review__draft'); + $transition->getFromState()->willReturn('needs_review'); + $transition->getToState()->willReturn('draft'); + $list[$transition->reveal()->id()] = $transition->reveal(); + + $transition = $this->prophesize(ModerationStateTransitionInterface::class); + $transition->id()->willReturn('draft__draft'); + $transition->getFromState()->willReturn('draft'); + $transition->getToState()->willReturn('draft'); + $list[$transition->reveal()->id()] = $transition->reveal(); + + $transition = $this->prophesize(ModerationStateTransitionInterface::class); + $transition->id()->willReturn('needs_review__needs_review'); + $transition->getFromState()->willReturn('needs_review'); + $transition->getToState()->willReturn('needs_review'); + $list[$transition->reveal()->id()] = $transition->reveal(); + + $transition = $this->prophesize(ModerationStateTransitionInterface::class); + $transition->id()->willReturn('published__published'); + $transition->getFromState()->willReturn('published'); + $transition->getToState()->willReturn('published'); + $list[$transition->reveal()->id()] = $transition->reveal(); + + return $list; + } + + /** + * Builds a mock storage object for States. + * + * @return EntityStorageInterface + */ + protected function setupStateStorage() { + $entity_storage = $this->prophesize(EntityStorageInterface::class); + + $state = $this->prophesize(ModerationStateInterface::class); + $state->id()->willReturn('draft'); + $state->label()->willReturn('Draft'); + $state->isPublishedState()->willReturn(FALSE); + $state->isDefaultRevisionState()->willReturn(FALSE); + $states['draft'] = $state->reveal(); + + $state = $this->prophesize(ModerationStateInterface::class); + $state->id()->willReturn('needs_review'); + $state->label()->willReturn('Needs Review'); + $state->isPublishedState()->willReturn(FALSE); + $state->isDefaultRevisionState()->willReturn(FALSE); + $states['needs_review'] = $state->reveal(); + + $state = $this->prophesize(ModerationStateInterface::class); + $state->id()->willReturn('published'); + $state->label()->willReturn('Published'); + $state->isPublishedState()->willReturn(TRUE); + $state->isDefaultRevisionState()->willReturn(TRUE); + $states['published'] = $state->reveal(); + + $entity_storage->loadMultiple()->willReturn($states); + + return $entity_storage->reveal(); + } + + /** + * Builds a mocked Entity Type Manager. + * + * @return EntityTypeManagerInterface + */ + protected function setupEntityTypeManager() { + $entityTypeManager = $this->prophesize(EntityTypeManagerInterface::class); + $entityTypeManager->getStorage('moderation_state')->willReturn($this->setupStateStorage()); + $entityTypeManager->getStorage('moderation_state_transition')->willReturn($this->setupTransitionStorage()); + + return $entityTypeManager->reveal(); + } + + /** + * Builds a mocked query factory that does nothing. + * + * @return QueryFactory + */ + protected function setupQueryFactory() { + $factory = $this->prophesize(QueryFactory::class); + + return $factory->reveal(); + } + + /** + * @covers ::isTransitionAllowed + * @covers ::calculatePossibleTransitions + */ + public function testIsTransitionAllowedWithValidTransition() { + $state_transition_validation = new StateTransitionValidation($this->setupEntityTypeManager(), $this->setupQueryFactory()); + + $this->assertTrue($state_transition_validation->isTransitionAllowed('draft', 'draft')); + $this->assertTrue($state_transition_validation->isTransitionAllowed('draft', 'needs_review')); + $this->assertTrue($state_transition_validation->isTransitionAllowed('needs_review', 'needs_review')); + $this->assertTrue($state_transition_validation->isTransitionAllowed('needs_review', 'staging')); + $this->assertTrue($state_transition_validation->isTransitionAllowed('staging', 'published')); + $this->assertTrue($state_transition_validation->isTransitionAllowed('needs_review', 'draft')); + } + + /** + * @covers ::isTransitionAllowed + * @covers ::calculatePossibleTransitions + */ + public function testIsTransitionAllowedWithInValidTransition() { + $state_transition_validation = new StateTransitionValidation($this->setupEntityTypeManager(), $this->setupQueryFactory()); + + $this->assertFalse($state_transition_validation->isTransitionAllowed('published', 'needs_review')); + $this->assertFalse($state_transition_validation->isTransitionAllowed('published', 'staging')); + $this->assertFalse($state_transition_validation->isTransitionAllowed('staging', 'needs_review')); + $this->assertFalse($state_transition_validation->isTransitionAllowed('staging', 'staging')); + $this->assertFalse($state_transition_validation->isTransitionAllowed('needs_review', 'published')); + $this->assertFalse($state_transition_validation->isTransitionAllowed('published', 'archived')); + $this->assertFalse($state_transition_validation->isTransitionAllowed('archived', 'published')); + } + + /** + * Verifies user-aware transition validation. + * + * @param string $from + * The state to transition from. + * @param string $to + * The state to transition to. + * @param string $permission + * The permission to give the user, or not. + * @param bool $allowed + * Whether or not to grant a user this permission. + * @param bool $result + * Whether userMayTransition() is expected to return TRUE or FALSE. + * + * @dataProvider userTransitionsProvider + */ + public function testUserSensitiveValidTransitions($from, $to, $permission, $allowed, $result) { + $user = $this->prophesize(AccountInterface::class); + // The one listed permission will be returned as instructed; Any others are + // always denied. + $user->hasPermission($permission)->willReturn($allowed); + $user->hasPermission(Argument::type('string'))->willReturn(FALSE); + + $validator = new Validator($this->setupEntityTypeManager(), $this->setupQueryFactory()); + + $this->assertEquals($result, $validator->userMayTransition($from, $to, $user->reveal())); + } + + /** + * Data provider for the user transition test. + * + * @return array + */ + public function userTransitionsProvider() { + // The user has the right permission, so let it through. + $ret[] = ['draft', 'draft', 'use draft__draft transition', TRUE, TRUE]; + + // The user doesn't have the right permission, block it. + $ret[] = ['draft', 'draft', 'use draft__draft transition', FALSE, FALSE]; + + // The user has some other permission that doesn't matter. + $ret[] = ['draft', 'draft', 'use draft__needs_review transition', TRUE, FALSE]; + + // The user has permission, but the transition isn't allowed anyway. + $ret[] = ['published', 'needs_review', 'use published__needs_review transition', TRUE, FALSE]; + + return $ret; + } + +} + +/** + * Testable subclass for selected tests. + * + * EntityQuery is beyond untestable, so we have to subclass and override the + * method that uses it. + */ +class Validator extends StateTransitionValidation { + /** + * @inheritDoc + */ + protected function getTransitionFromStates($from, $to) { + if ($from == 'draft' && $to == 'draft') { + return $this->transitionStorage()->loadMultiple(['draft__draft'])[0]; + } + } + +} diff --git a/core/modules/file/file.module b/core/modules/file/file.module index d69c149..96bc90e 100644 --- a/core/modules/file/file.module +++ b/core/modules/file/file.module @@ -185,6 +185,8 @@ function file_copy(FileInterface $source, $destination = NULL, $replace = FILE_E /** * Moves a file to a new location and update the file's database entry. * + * Moving a file is performed by copying the file to the new location and then + * deleting the original. * - Checks if $source and $destination are valid and readable/writable. * - Performs a file move if $source is not equal to $destination. * - If file already exists in $destination either the call will error out, diff --git a/core/modules/language/migration_templates/d6_language_content_settings.yml b/core/modules/language/migration_templates/d6_language_content_settings.yml deleted file mode 100644 index e5dc750..0000000 --- a/core/modules/language/migration_templates/d6_language_content_settings.yml +++ /dev/null @@ -1,44 +0,0 @@ -id: d6_language_content_settings -label: Drupal 6 language content settings -migration_tags: - - Drupal 6 -source: - plugin: d6_language_content_settings - constants: - target_type: 'node' -process: -# Ignore i18n_node_options_[node_type] options not available in Drupal 8, -# i18n_required_node and i18n_newnode_current - target_bundle: type - target_entity_type_id: 'constants/target_type' - default_langcode: - - - plugin: static_map - source: language_content_type - map: - 0: NULL - 1: 'current_interface' - 2: 'current_interface' - - - plugin: skip_on_empty - method: row - language_alterable: - plugin: static_map - source: i18n_lock_node - map: - 0: true - 1: false - 'third_party_settings/content_translation/enabled': - plugin: static_map - source: language_content_type - map: - # In the case of being 0, it will be skipped. We are not actually setting - # a null value. - 0: NULL - 1: false - 2: true -destination: - plugin: entity:language_content_settings -migration_dependencies: - required: - - d6_node_type diff --git a/core/modules/language/migration_templates/d7_language_content_settings.yml b/core/modules/language/migration_templates/d7_language_content_settings.yml deleted file mode 100644 index cbe935a..0000000 --- a/core/modules/language/migration_templates/d7_language_content_settings.yml +++ /dev/null @@ -1,44 +0,0 @@ -id: d7_language_content_settings -label: Drupal 7 language content settings -migration_tags: - - Drupal 7 -source: - plugin: d7_language_content_settings - constants: - target_type: 'node' -process: -# Ignore i18n_node_options_[node_type] options not available in Drupal 8, -# i18n_required_node and i18n_newnode_current - target_bundle: type - target_entity_type_id: 'constants/target_type' - default_langcode: - - - plugin: static_map - source: language_content_type - map: - 0: NULL - 1: 'current_interface' - 2: 'current_interface' - - - plugin: skip_on_empty - method: row - language_alterable: - plugin: static_map - source: i18n_lock_node - map: - 0: true - 1: false - 'third_party_settings/content_translation/enabled': - plugin: static_map - source: language_content_type - map: - # In the case of being 0, it will be skipped. We are not actually setting - # a null value. - 0: NULL - 1: false - 2: true -destination: - plugin: entity:language_content_settings -migration_dependencies: - required: - - d7_node_type diff --git a/core/modules/language/src/Plugin/migrate/source/d6/LanguageContentSettings.php b/core/modules/language/src/Plugin/migrate/source/d6/LanguageContentSettings.php deleted file mode 100644 index c41b635..0000000 --- a/core/modules/language/src/Plugin/migrate/source/d6/LanguageContentSettings.php +++ /dev/null @@ -1,57 +0,0 @@ -select('node_type', 't') - ->fields('t', array( - 'type', - )); - } - - /** - * {@inheritdoc} - */ - public function fields() { - $fields = array( - 'type' => $this->t('Type'), - 'language_content_type' => $this->t('Multilingual support.'), - 'i18n_lock_node' => $this->t('Lock language.'), - ); - return $fields; - } - - /** - * {@inheritdoc} - */ - public function prepareRow(Row $row) { - $type = $row->getSourceProperty('type'); - $row->setSourceProperty('language_content_type', $this->variableGet('language_content_type_' . $type, NULL)); - $row->setSourceProperty('i18n_lock_node', $this->variableGet('i18n_lock_node_' . $type, 0)); - return parent::prepareRow($row); - } - - /** - * {@inheritdoc} - */ - public function getIds() { - $ids['type']['type'] = 'string'; - return $ids; - } - -} diff --git a/core/modules/language/src/Plugin/migrate/source/d7/LanguageContentSettings.php b/core/modules/language/src/Plugin/migrate/source/d7/LanguageContentSettings.php deleted file mode 100644 index 91ed020..0000000 --- a/core/modules/language/src/Plugin/migrate/source/d7/LanguageContentSettings.php +++ /dev/null @@ -1,63 +0,0 @@ -select('node_type', 't') - ->fields('t', array( - 'type', - )); - } - - /** - * {@inheritdoc} - */ - public function fields() { - $fields = array( - 'type' => $this->t('Type'), - 'language_content_type' => $this->t('Multilingual support.'), - 'i18n_lock_node' => $this->t('Lock language.'), - ); - return $fields; - } - - /** - * {@inheritdoc} - */ - public function prepareRow(Row $row) { - $type = $row->getSourceProperty('type'); - $row->setSourceProperty('language_content_type', $this->variableGet('language_content_type_' . $type, NULL)); - $i18n_node_options = $this->variableGet('i18n_node_options_' . $type, NULL); - if ($i18n_node_options && in_array('lock', $i18n_node_options)) { - $row->setSourceProperty('i18n_lock_node', 1); - } - else { - $row->setSourceProperty('i18n_lock_node', 0); - } - return parent::prepareRow($row); - } - - /** - * {@inheritdoc} - */ - public function getIds() { - $ids['type']['type'] = 'string'; - return $ids; - } - -} diff --git a/core/modules/language/tests/src/Kernel/Migrate/d6/MigrateLanguageContentSettingsTest.php b/core/modules/language/tests/src/Kernel/Migrate/d6/MigrateLanguageContentSettingsTest.php deleted file mode 100644 index 0420d70..0000000 --- a/core/modules/language/tests/src/Kernel/Migrate/d6/MigrateLanguageContentSettingsTest.php +++ /dev/null @@ -1,59 +0,0 @@ -installConfig(['node']); - $this->executeMigrations(['d6_node_type', 'd6_language_content_settings']); - } - - /** - * Tests migration of content language settings. - */ - public function testLanguageContent() { - // Assert that a translatable content is still translatable. - $config = $this->config('language.content_settings.node.article'); - $this->assertSame($config->get('target_entity_type_id'), 'node'); - $this->assertSame($config->get('target_bundle'), 'article'); - $this->assertSame($config->get('default_langcode'), 'current_interface'); - $this->assertTrue($config->get('third_party_settings.content_translation.enabled')); - - // Assert that a non-translatable content is not translatable. - $config = ContentLanguageSettings::loadByEntityTypeBundle('node', 'company'); - $this->assertTrue($config->isDefaultConfiguration()); - $this->assertFalse($config->isLanguageAlterable()); - $this->assertSame($config->getDefaultLangcode(), 'site_default'); - } - - /** - * Tests migration of content language settings when there is no language lock. - */ - public function testLanguageContentWithNoLanguageLock() { - // Assert that a we can assign a language. - $config = ContentLanguageSettings::loadByEntityTypeBundle('node', 'employee'); - $this->assertSame($config->getDefaultLangcode(), 'current_interface'); - $this->assertTrue($config->isLanguageAlterable()); - } - -} diff --git a/core/modules/language/tests/src/Kernel/Migrate/d7/MigrateLanguageContentSettingsTest.php b/core/modules/language/tests/src/Kernel/Migrate/d7/MigrateLanguageContentSettingsTest.php deleted file mode 100644 index defc6cd..0000000 --- a/core/modules/language/tests/src/Kernel/Migrate/d7/MigrateLanguageContentSettingsTest.php +++ /dev/null @@ -1,50 +0,0 @@ -installConfig(['node']); - $this->executeMigrations(['d7_node_type', 'd7_language_content_settings']); - } - - /** - * Tests migration of content language settings. - */ - public function testLanguageContent() { - // Assert that a translatable content is still translatable. - $config = $this->config('language.content_settings.node.blog'); - $this->assertIdentical($config->get('target_entity_type_id'), 'node'); - $this->assertIdentical($config->get('target_bundle'), 'blog'); - $this->assertIdentical($config->get('default_langcode'), 'current_interface'); - $this->assertFalse($config->get('language_alterable')); - $this->assertTrue($config->get('third_party_settings.content_translation.enabled')); - - // Assert that a non-translatable content is not translatable. - $config = ContentLanguageSettings::loadByEntityTypeBundle('node', 'page'); - $this->assertTrue($config->isDefaultConfiguration()); - $this->assertFalse($config->isLanguageAlterable()); - $this->assertSame($config->getDefaultLangcode(), 'site_default'); - - } - -} diff --git a/core/modules/migrate/tests/modules/template_test/migration_templates/migrate.migration.node_template.yml b/core/modules/migrate/tests/modules/template_test/migration_templates/migrate.migration.node_template.yml new file mode 100644 index 0000000..6eead8b --- /dev/null +++ b/core/modules/migrate/tests/modules/template_test/migration_templates/migrate.migration.node_template.yml @@ -0,0 +1,10 @@ +id: node_template +label: Template test - node +migration_tags: + - Template Test +source: + plugin: empty +process: + src: barfoo +destination: + plugin: entity:node diff --git a/core/modules/migrate/tests/modules/template_test/migration_templates/migrate.migration.other_template.yml b/core/modules/migrate/tests/modules/template_test/migration_templates/migrate.migration.other_template.yml new file mode 100644 index 0000000..3d974f0 --- /dev/null +++ b/core/modules/migrate/tests/modules/template_test/migration_templates/migrate.migration.other_template.yml @@ -0,0 +1,10 @@ +id: other_template +label: Template with a different tag +migration_tags: + - Different Template Test +source: + plugin: empty +process: + src: raboof +destination: + plugin: entity:user diff --git a/core/modules/migrate/tests/modules/template_test/migration_templates/migrate.migration.url_template.yml b/core/modules/migrate/tests/modules/template_test/migration_templates/migrate.migration.url_template.yml new file mode 100644 index 0000000..0204046 --- /dev/null +++ b/core/modules/migrate/tests/modules/template_test/migration_templates/migrate.migration.url_template.yml @@ -0,0 +1,10 @@ +id: url_template +label: Template test - URL +migration_tags: + - Template Test +source: + plugin: empty +process: + src: foobar +destination: + plugin: url_alias diff --git a/core/modules/migrate/tests/modules/template_test/template_test.info.yml b/core/modules/migrate/tests/modules/template_test/template_test.info.yml new file mode 100644 index 0000000..d396c63 --- /dev/null +++ b/core/modules/migrate/tests/modules/template_test/template_test.info.yml @@ -0,0 +1,5 @@ +name: 'Migration template test' +type: module +package: Testing +version: VERSION +core: 8.x diff --git a/core/modules/migrate_drupal/src/Plugin/migrate/cckfield/CckFieldPluginBase.php b/core/modules/migrate_drupal/src/Plugin/migrate/cckfield/CckFieldPluginBase.php index d942bce..87d5457 100644 --- a/core/modules/migrate_drupal/src/Plugin/migrate/cckfield/CckFieldPluginBase.php +++ b/core/modules/migrate_drupal/src/Plugin/migrate/cckfield/CckFieldPluginBase.php @@ -10,7 +10,7 @@ /** * The base class for all cck field plugins. * - * @see \Drupal\migrate\Plugin\MigratePluginManager + * @see \Drupal\migrate_drupal\Plugin\MigratePluginManager * @see \Drupal\migrate_drupal\Annotation\MigrateCckField * @see \Drupal\migrate_drupal\Plugin\MigrateCckFieldInterface * @see plugin_api diff --git a/core/modules/migrate_drupal/tests/fixtures/drupal6.php b/core/modules/migrate_drupal/tests/fixtures/drupal6.php index 7d1c020..4242cad 100644 --- a/core/modules/migrate_drupal/tests/fixtures/drupal6.php +++ b/core/modules/migrate_drupal/tests/fixtures/drupal6.php @@ -45534,14 +45534,6 @@ 'value' => 's:1:"2";', )) ->values(array( - 'name' => 'language_content_type_employee', - 'value' => 's:1:"2";', -)) -->values(array( - 'name' => 'i18n_lock_node_article', - 'value' => 'i:1;', -)) -->values(array( 'name' => 'language_count', 'value' => 'i:11;', )) diff --git a/core/modules/migrate_drupal/tests/fixtures/drupal7.php b/core/modules/migrate_drupal/tests/fixtures/drupal7.php index 258821f..2797b6c 100644 --- a/core/modules/migrate_drupal/tests/fixtures/drupal7.php +++ b/core/modules/migrate_drupal/tests/fixtures/drupal7.php @@ -41323,10 +41323,6 @@ 'value' => 's:1:"0";', )) ->values(array( - 'name' => 'i18n_node_options_blog', - 'value' => 'a:2:{i:0;s:8:"required";i:1;s:4:"lock";}', -)) -->values(array( 'name' => 'language_count', 'value' => 'i:2;', )) diff --git a/core/modules/simpletest/tests/src/Functional/BrowserTestBaseTest.php b/core/modules/simpletest/tests/src/Functional/BrowserTestBaseTest.php index 796360f..d321a4b 100644 --- a/core/modules/simpletest/tests/src/Functional/BrowserTestBaseTest.php +++ b/core/modules/simpletest/tests/src/Functional/BrowserTestBaseTest.php @@ -33,9 +33,6 @@ public function testGoTo() { // Test page contains some text. $this->assertSession()->pageTextContains('Test page text.'); - // Response includes cache tags that we can assert. - $this->assertSession()->responseHeaderEquals('X-Drupal-Cache-Tags', 'rendered'); - // Test drupalGet with a url object. $url = Url::fromRoute('test_page_test.render_title'); $this->drupalGet($url); diff --git a/core/modules/taxonomy/migration_templates/d7_taxonomy_term.yml b/core/modules/taxonomy/migration_templates/d7_taxonomy_term.yml index 3b6f7bf..7546660 100644 --- a/core/modules/taxonomy/migration_templates/d7_taxonomy_term.yml +++ b/core/modules/taxonomy/migration_templates/d7_taxonomy_term.yml @@ -11,8 +11,7 @@ process: migration: d7_taxonomy_vocabulary source: vid name: name - 'description/value': description - 'description/format': format + description: description weight: weight parent: plugin: migration diff --git a/core/modules/taxonomy/tests/src/Kernel/Migrate/d7/MigrateTaxonomyTermTest.php b/core/modules/taxonomy/tests/src/Kernel/Migrate/d7/MigrateTaxonomyTermTest.php index 37e56aa..8a2ea11 100644 --- a/core/modules/taxonomy/tests/src/Kernel/Migrate/d7/MigrateTaxonomyTermTest.php +++ b/core/modules/taxonomy/tests/src/Kernel/Migrate/d7/MigrateTaxonomyTermTest.php @@ -35,21 +35,18 @@ protected function setUp() { * The parent vocabulary the migrated entity should have. * @param string $expected_description * The description the migrated entity should have. - * @param string $expected_format - * The format the migrated entity should have. * @param int $expected_weight * The weight the migrated entity should have. * @param array $expected_parents * The parent terms the migrated entity should have. */ - protected function assertEntity($id, $expected_label, $expected_vid, $expected_description = '', $expected_format = NULL, $expected_weight = 0, $expected_parents = []) { + protected function assertEntity($id, $expected_label, $expected_vid, $expected_description = '', $expected_weight = 0, $expected_parents = []) { /** @var \Drupal\taxonomy\TermInterface $entity */ $entity = Term::load($id); $this->assertTrue($entity instanceof TermInterface); $this->assertIdentical($expected_label, $entity->label()); $this->assertIdentical($expected_vid, $entity->getVocabularyId()); $this->assertEqual($expected_description, $entity->getDescription()); - $this->assertEquals($expected_format, $entity->getFormat()); $this->assertEqual($expected_weight, $entity->getWeight()); $this->assertIdentical($expected_parents, $this->getParentIDs($id)); } @@ -58,14 +55,14 @@ protected function assertEntity($id, $expected_label, $expected_vid, $expected_d * Tests the Drupal 7 taxonomy term to Drupal 8 migration. */ public function testTaxonomyTerms() { - $this->assertEntity(1, 'General discussion', 'forums', '', NULL, 2); - $this->assertEntity(2, 'Term1', 'test_vocabulary', 'The first term.', 'filtered_html'); - $this->assertEntity(3, 'Term2', 'test_vocabulary', 'The second term.', 'filtered_html'); - $this->assertEntity(4, 'Term3', 'test_vocabulary', 'The third term.', 'full_html', 0, [3]); - $this->assertEntity(5, 'Custom Forum', 'forums', 'Where the cool kids are.', NULL, 3); - $this->assertEntity(6, 'Games', 'forums', '', NULL, 4); - $this->assertEntity(7, 'Minecraft', 'forums', '', NULL, 1, [6]); - $this->assertEntity(8, 'Half Life 3', 'forums', '', NULL, 0, [6]); + $this->assertEntity(1, 'General discussion', 'forums', '', 2); + $this->assertEntity(2, 'Term1', 'test_vocabulary', 'The first term.'); + $this->assertEntity(3, 'Term2', 'test_vocabulary', 'The second term.'); + $this->assertEntity(4, 'Term3', 'test_vocabulary', 'The third term.', 0, [3]); + $this->assertEntity(5, 'Custom Forum', 'forums', 'Where the cool kids are.', 3); + $this->assertEntity(6, 'Games', 'forums', '', 4); + $this->assertEntity(7, 'Minecraft', 'forums', '', 1, [6]); + $this->assertEntity(8, 'Half Life 3', 'forums', '', 0, [6]); } /** diff --git a/core/modules/views/config/schema/views.display.schema.yml b/core/modules/views/config/schema/views.display.schema.yml index d17c48c..511b0b9 100644 --- a/core/modules/views/config/schema/views.display.schema.yml +++ b/core/modules/views/config/schema/views.display.schema.yml @@ -34,12 +34,6 @@ views.display.page: weight: type: integer label: 'Weight' - enabled: - type: boolean - label: 'Enabled' - expanded: - type: boolean - label: 'Expanded' menu_name: type: string label: 'Menu name' diff --git a/core/modules/views/src/Plugin/Menu/ViewsMenuLink.php b/core/modules/views/src/Plugin/Menu/ViewsMenuLink.php index b484c24..33a2fd2 100644 --- a/core/modules/views/src/Plugin/Menu/ViewsMenuLink.php +++ b/core/modules/views/src/Plugin/Menu/ViewsMenuLink.php @@ -26,6 +26,7 @@ class ViewsMenuLink extends MenuLinkBase implements ContainerFactoryPluginInterf 'enabled' => 1, 'title' => 1, 'description' => 1, + 'metadata' => 1, ); /** @@ -139,9 +140,9 @@ public function updateLink(array $new_definition_values, $persist) { $display = &$view->storage->getDisplay($view->current_display); // Just save the title to the original view. $changed = FALSE; - foreach ($overrides as $key => $new_definition_value) { - if (empty($display['display_options']['menu'][$key]) || $display['display_options']['menu'][$key] != $new_definition_value) { - $display['display_options']['menu'][$key] = $new_definition_value; + foreach ($new_definition_values as $key => $new_definition_value) { + if (isset($display['display_options']['menu'][$key]) && $display['display_options']['menu'][$key] != $new_definition_values[$key]) { + $display['display_options']['menu'][$key] = $new_definition_values[$key]; $changed = TRUE; } } diff --git a/core/modules/views/src/Plugin/views/display/Page.php b/core/modules/views/src/Plugin/views/display/Page.php index d79b75b..a046698 100644 --- a/core/modules/views/src/Plugin/views/display/Page.php +++ b/core/modules/views/src/Plugin/views/display/Page.php @@ -124,7 +124,6 @@ protected function defineOptions() { 'title' => array('default' => ''), 'description' => array('default' => ''), 'weight' => array('default' => 0), - 'enabled' => array('default' => TRUE), 'menu_name' => array('default' => 'main'), 'parent' => array('default' => ''), 'context' => array('default' => ''), diff --git a/core/modules/views/src/Plugin/views/display/PathPluginBase.php b/core/modules/views/src/Plugin/views/display/PathPluginBase.php index 966cc1a..9ee7fd2 100644 --- a/core/modules/views/src/Plugin/views/display/PathPluginBase.php +++ b/core/modules/views/src/Plugin/views/display/PathPluginBase.php @@ -322,8 +322,6 @@ public function getMenuLinks() { $links[$menu_link_id]['title'] = $menu['title']; $links[$menu_link_id]['description'] = $menu['description']; $links[$menu_link_id]['parent'] = $menu['parent']; - $links[$menu_link_id]['enabled'] = $menu['enabled']; - $links[$menu_link_id]['expanded'] = $menu['expanded']; if (isset($menu['weight'])) { $links[$menu_link_id]['weight'] = intval($menu['weight']); diff --git a/core/modules/views/tests/src/Kernel/Plugin/Display/ViewsMenuLinkTest.php b/core/modules/views/tests/src/Kernel/Plugin/Display/ViewsMenuLinkTest.php deleted file mode 100644 index 9e05ebc..0000000 --- a/core/modules/views/tests/src/Kernel/Plugin/Display/ViewsMenuLinkTest.php +++ /dev/null @@ -1,98 +0,0 @@ -entityManger = $this->container->get('entity.manager'); - $this->menuLinkManager = $this->container->get('plugin.manager.menu.link'); - $this->menuLinkOverrides = $this->container->get('menu_link.static.overrides'); - } - - /** - * Test views internal menu link options. - */ - public function testMenuLinkOverrides() { - // Link from views module. - $views_link = $this->menuLinkManager->getDefinition('views_view:views.test_page_display_menu.page_3'); - $this->assertTrue($views_link['enabled'], 'Menu link is enabled.'); - $this->assertFalse($views_link['expanded'], 'Menu link is not expanded.'); - $views_link['enabled'] = 0; - $views_link['expanded'] = 1; - $this->menuLinkManager->updateDefinition($views_link['id'], $views_link); - $views_link = $this->menuLinkManager->getDefinition($views_link['id']); - $this->assertFalse($views_link['enabled'], 'Menu link is disabled.'); - $this->assertTrue($views_link['expanded'], 'Menu link is expanded.'); - $this->menuLinkManager->rebuild(); - $this->assertFalse($views_link['enabled'], 'Menu link is disabled.'); - $this->assertTrue($views_link['expanded'], 'Menu link is expanded.'); - - // Link from user module. - $user_link = $this->menuLinkManager->getDefinition('user.page'); - $this->assertTrue($user_link['enabled'], 'Menu link is enabled.'); - $user_link['enabled'] = 0; - $views_link['expanded'] = 1; - $this->menuLinkManager->updateDefinition($user_link['id'], $user_link); - $this->assertFalse($user_link['enabled'], 'Menu link is disabled.'); - $this->menuLinkManager->rebuild(); - $this->assertFalse($user_link['enabled'], 'Menu link is disabled.'); - - $this->menuLinkOverrides->reload(); - - $views_link = $this->menuLinkManager->getDefinition('views_view:views.test_page_display_menu.page_3'); - $this->assertFalse($views_link['enabled'], 'Menu link is disabled.'); - $this->assertTrue($views_link['expanded'], 'Menu link is expanded.'); - - $user_link = $this->menuLinkManager->getDefinition('user.page'); - $this->assertFalse($user_link['enabled'], 'Menu link is disabled.'); - } - -} diff --git a/core/modules/views/views.install b/core/modules/views/views.install index 30ac79c..8c5041c 100644 --- a/core/modules/views/views.install +++ b/core/modules/views/views.install @@ -368,28 +368,6 @@ function views_update_8100() { } /** - * Set default values for enabled/expanded flag on page displays. - */ -function views_update_8101() { - $config_factory = \Drupal::configFactory(); - foreach ($config_factory->listAll('views.view.') as $view_config_name) { - $view = $config_factory->getEditable($view_config_name); - $save = FALSE; - foreach ($view->get('display') as $display_id => $display) { - if ($display['display_plugin'] == 'page') { - $display['display_options']['menu']['enabled'] = TRUE; - $display['display_options']['menu']['expanded'] = FALSE; - $view->set("display.$display_id", $display); - $save = TRUE; - } - } - if ($save) { - $view->save(); - } - } -} - -/** * @} End of "addtogroup updates-8.1.0". */ diff --git a/core/tests/Drupal/FunctionalTests/AssertLegacyTrait.php b/core/tests/Drupal/FunctionalTests/AssertLegacyTrait.php index 0789fc8..d63ada6 100644 --- a/core/tests/Drupal/FunctionalTests/AssertLegacyTrait.php +++ b/core/tests/Drupal/FunctionalTests/AssertLegacyTrait.php @@ -240,7 +240,7 @@ protected function assertUrl($path) { * @deprecated Scheduled for removal in Drupal 9.0.0. * Use $this->assertSession()->assertNoEscaped() instead. */ - protected function assertNoEscaped($raw) { + public function assertNoEscaped($raw) { $this->assertSession()->assertNoEscaped($raw); } diff --git a/core/tests/Drupal/KernelTests/Core/Theme/StableTemplateOverrideTest.php b/core/tests/Drupal/KernelTests/Core/Theme/StableTemplateOverrideTest.php index 5e2265d..f4e242d 100644 --- a/core/tests/Drupal/KernelTests/Core/Theme/StableTemplateOverrideTest.php +++ b/core/tests/Drupal/KernelTests/Core/Theme/StableTemplateOverrideTest.php @@ -24,6 +24,7 @@ class StableTemplateOverrideTest extends KernelTestBase { */ protected $templatesToSkip = [ 'views-form-views-form', + 'entity-moderation-form' ]; /** diff --git a/core/tests/Drupal/Tests/BrowserTestBase.php b/core/tests/Drupal/Tests/BrowserTestBase.php index c701dbd..07e1fa9 100644 --- a/core/tests/Drupal/Tests/BrowserTestBase.php +++ b/core/tests/Drupal/Tests/BrowserTestBase.php @@ -6,8 +6,6 @@ use Behat\Mink\Element\Element; use Behat\Mink\Mink; use Behat\Mink\Session; -use Drupal\Component\FileCache\FileCacheFactory; -use Drupal\Component\Serialization\Yaml; use Drupal\Component\Utility\Html; use Drupal\Component\Utility\SafeMarkup; use Drupal\Component\Utility\UrlHelper; @@ -1077,14 +1075,11 @@ public function installDrupal() { // testing specific overrides. file_put_contents($directory . '/settings.php', "\n\$test_class = '" . get_class($this) . "';\n" . 'include DRUPAL_ROOT . \'/\' . $site_path . \'/settings.testing.php\';' . "\n", FILE_APPEND); } - $settings_services_file = DRUPAL_ROOT . '/' . $this->originalSiteDirectory . '/testing.services.yml'; - if (!file_exists($settings_services_file)) { - // Otherwise, use the default services as a starting point for overrides. - $settings_services_file = DRUPAL_ROOT . '/sites/default/default.services.yml'; + if (file_exists($settings_services_file)) { + // Copy the testing-specific service overrides in place. + copy($settings_services_file, $directory . '/services.yml'); } - // Copy the testing-specific service overrides in place. - copy($settings_services_file, $directory . '/services.yml'); // Since Drupal is bootstrapped already, install_begin_request() will not // bootstrap into DRUPAL_BOOTSTRAP_CONFIGURATION (again). Hence, we have to @@ -1109,10 +1104,6 @@ public function installDrupal() { // using File API; a potential error must trigger a PHP warning. chmod($directory, 0777); - // During tests, cacheable responses should get the debugging cacheability - // headers by default. - $this->setContainerParameter('http.response.debug_cacheability_headers', TRUE); - $request = \Drupal::request(); $this->kernel = DrupalKernel::createFromRequest($request, $this->classLoader, 'prod', TRUE); $this->kernel->prepareLegacyRequest($request); @@ -1514,7 +1505,6 @@ protected function resetAll() { */ protected function refreshVariables() { // Clear the tag cache. - $this->container->get('cache_tags.invalidator')->resetChecksums(); // @todo Replace drupal_static() usage within classes and provide a // proper interface for invoking reset() on a cache backend: // https://www.drupal.org/node/2311945. @@ -1799,25 +1789,4 @@ public static function assertEquals($expected, $actual, $message = '', $delta = parent::assertEquals($expected, $actual, $message, $delta, $maxDepth, $canonicalize, $ignoreCase); } - /** - * Changes parameters in the services.yml file. - * - * @param string $name - * The name of the parameter. - * @param mixed $value - * The value of the parameter. - */ - protected function setContainerParameter($name, $value) { - $filename = $this->siteDirectory . '/services.yml'; - chmod($filename, 0666); - - $services = Yaml::decode(file_get_contents($filename)); - $services['parameters'][$name] = $value; - file_put_contents($filename, Yaml::encode($services)); - - // Ensure that the cache is deleted for the yaml file loader. - $file_cache = FileCacheFactory::get('container_yaml_loader'); - $file_cache->delete($filename); - } - } diff --git a/core/tests/Drupal/Tests/Core/Cache/ChainedFastBackendTest.php b/core/tests/Drupal/Tests/Core/Cache/ChainedFastBackendTest.php index 0b576b7..956c18d 100644 --- a/core/tests/Drupal/Tests/Core/Cache/ChainedFastBackendTest.php +++ b/core/tests/Drupal/Tests/Core/Cache/ChainedFastBackendTest.php @@ -34,21 +34,6 @@ class ChainedFastBackendTest extends UnitTestCase { protected $bin; /** - * Tests that chained fast backend cannot be constructed with two instances of - * the same service. - */ - public function testConsistentAndFastBackendCannotBeTheSameService() { - // ToDo: It should throw a proper exception. See https://www.drupal.org/node/2751847. - $this->setExpectedException(\PHPUnit_Framework_Error::class, 'Consistent cache backend and fast cache backend cannot use the same service.'); - $cache = $this->getMock('Drupal\Core\Cache\CacheBackendInterface'); - $chained_fast_backend = new ChainedFastBackend( - $cache, - $cache, - 'foo' - ); - } - - /** * Tests a get() on the fast backend, with no hit on the consistent backend. */ public function testGetDoesntHitConsistentBackend() {