diff --git a/plugins/woocommerce/changelog/add-features-controller-with-plugin-compatibility-declaration b/plugins/woocommerce/changelog/add-features-controller-with-plugin-compatibility-declaration new file mode 100644 index 00000000000..71b18bf9621 --- /dev/null +++ b/plugins/woocommerce/changelog/add-features-controller-with-plugin-compatibility-declaration @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add the WooCommerce features engine diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php index 56dfb5624e5..6f0448c26c2 100644 --- a/plugins/woocommerce/includes/class-woocommerce.php +++ b/plugins/woocommerce/includes/class-woocommerce.php @@ -12,6 +12,7 @@ use Automattic\WooCommerce\Internal\AssignDefaultCategory; use Automattic\WooCommerce\Internal\BatchProcessing\BatchProcessingController; use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController; use Automattic\WooCommerce\Internal\DownloadPermissionsAdjuster; +use Automattic\WooCommerce\Internal\Features\FeaturesController; use Automattic\WooCommerce\Internal\ProductAttributesLookup\DataRegenerator; use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore; use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register as ProductDownloadDirectories; @@ -230,6 +231,7 @@ final class WooCommerce { $container->get( CustomOrdersTableController::class ); $container->get( OptionSanitizer::class ); $container->get( BatchProcessingController::class ); + $container->get( FeaturesController::class ); } /** diff --git a/plugins/woocommerce/src/Admin/Features/Features.php b/plugins/woocommerce/src/Admin/Features/Features.php index 503e2683407..bf797ef1eb9 100644 --- a/plugins/woocommerce/src/Admin/Features/Features.php +++ b/plugins/woocommerce/src/Admin/Features/Features.php @@ -8,6 +8,7 @@ namespace Automattic\WooCommerce\Admin\Features; use Automattic\WooCommerce\Admin\PageController; use Automattic\WooCommerce\Internal\Admin\Loader; use Automattic\WooCommerce\Internal\Admin\WCAdminAssets; +use Automattic\WooCommerce\Internal\Features\FeaturesController; /** * Features Class. @@ -61,8 +62,6 @@ class Features { $this->register_internal_class_aliases(); // Load feature before WooCommerce update hooks. add_action( 'init', array( __CLASS__, 'load_features' ), 4 ); - add_filter( 'woocommerce_get_sections_advanced', array( __CLASS__, 'add_features_section' ) ); - add_filter( 'woocommerce_get_settings_advanced', array( __CLASS__, 'add_features_settings' ), 10, 2 ); add_action( 'admin_enqueue_scripts', array( __CLASS__, 'maybe_load_beta_features_modal' ) ); add_action( 'admin_enqueue_scripts', array( __CLASS__, 'load_scripts' ), 15 ); add_filter( 'admin_body_class', array( __CLASS__, 'add_admin_body_classes' ) ); @@ -250,70 +249,26 @@ class Features { /** * Adds the Features section to the advanced tab of WooCommerce Settings * + * @deprecated 7.0 The WooCommerce Admin features are now handled by the WooCommerce features engine (see the FeaturesController class). + * * @param array $sections Sections. * @return array */ public static function add_features_section( $sections ) { - $features = apply_filters( - 'woocommerce_settings_features', - array() - ); - - if ( empty( $features ) ) { - return $sections; - } - - $sections['features'] = __( 'Features', 'woocommerce' ); return $sections; } /** * Adds the Features settings. * + * @deprecated 7.0 The WooCommerce Admin features are now handled by the WooCommerce features engine (see the FeaturesController class). + * * @param array $settings Settings. * @param string $current_section Current section slug. * @return array */ public static function add_features_settings( $settings, $current_section ) { - if ( 'features' !== $current_section ) { - return $settings; - } - - $features = apply_filters( - 'woocommerce_settings_features', - array() - ); - - $features_disabled = apply_filters( 'woocommerce_admin_disabled', false ); - - if ( ! $features_disabled && empty( $features ) ) { - return $settings; - } - - $desc = __( 'Start using new features that are being progressively rolled out to improve the store management experience.', 'woocommerce' ); - $disabled_desc = __( 'WooCommerce features have been disabled.', 'woocommerce' ); - - if ( $features_disabled ) { - $GLOBALS['hide_save_button'] = true; - } - - return array_merge( - array( - array( - 'title' => __( 'Features', 'woocommerce' ), - 'type' => 'title', - 'desc' => $features_disabled ? $disabled_desc : $desc, - 'id' => 'features_options', - ), - ), - $features_disabled ? array() : $features, - array( - array( - 'type' => 'sectionend', - 'id' => 'features_options', - ), - ) - ); + return $settings; } /** diff --git a/plugins/woocommerce/src/Admin/Features/Navigation/Init.php b/plugins/woocommerce/src/Admin/Features/Navigation/Init.php index 4c182c5ff6d..0b832f24515 100644 --- a/plugins/woocommerce/src/Admin/Features/Navigation/Init.php +++ b/plugins/woocommerce/src/Admin/Features/Navigation/Init.php @@ -34,7 +34,6 @@ class Init { * Hook into WooCommerce. */ public function __construct() { - add_filter( 'woocommerce_settings_features', array( $this, 'add_feature_toggle' ) ); add_action( 'update_option_' . self::TOGGLE_OPTION_NAME, array( $this, 'reload_page_on_toggle' ), 10, 2 ); add_action( 'woocommerce_settings_saved', array( $this, 'maybe_reload_page' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'maybe_enqueue_opt_out_scripts' ) ); @@ -49,34 +48,12 @@ class Init { /** * Add the feature toggle to the features settings. * + * @deprecated 7.0 The WooCommerce Admin features are now handled by the WooCommerce features engine (see the FeaturesController class). + * * @param array $features Feature sections. * @return array */ public static function add_feature_toggle( $features ) { - $description = __( - 'Adds the new WooCommerce navigation experience to the dashboard', - 'woocommerce' - ); - $update_text = ''; - $needs_update = version_compare( get_bloginfo( 'version' ), '5.6', '<' ); - if ( $needs_update && current_user_can( 'update_core' ) && current_user_can( 'update_php' ) ) { - $update_text = sprintf( - /* translators: 1: line break tag, 2: open link to WordPress update link, 3: close link tag. */ - __( '%1$s %2$sUpdate WordPress to enable the new navigation%3$s', 'woocommerce' ), - '
', - '', - '' - ); - } - - $features[] = array( - 'title' => __( 'Navigation', 'woocommerce' ), - 'desc' => $description . $update_text, - 'id' => self::TOGGLE_OPTION_NAME, - 'type' => 'checkbox', - 'class' => $needs_update ? 'disabled' : '', - ); - return $features; } @@ -119,7 +96,7 @@ class Init { return; } - if ( $value !== 'yes' ) { + if ( 'yes' !== $value ) { update_option( 'woocommerce_navigation_show_opt_out', 'yes' ); } diff --git a/plugins/woocommerce/src/Container.php b/plugins/woocommerce/src/Container.php index 9166e6b4cbe..3815db81840 100644 --- a/plugins/woocommerce/src/Container.php +++ b/plugins/woocommerce/src/Container.php @@ -9,6 +9,7 @@ use Automattic\WooCommerce\Internal\DependencyManagement\ExtendedContainer; use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\COTMigrationServiceProvider; use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\DownloadPermissionsAdjusterServiceProvider; use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\AssignDefaultCategoryServiceProvider; +use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\FeaturesServiceProvider; use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\OrdersControllersServiceProvider; use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\OrderAdminServiceProvider; use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\OrderMetaBoxServiceProvider; @@ -63,6 +64,7 @@ final class Container { BatchProcessingServiceProvider::class, OrderMetaBoxServiceProvider::class, OrderAdminServiceProvider::class, + FeaturesServiceProvider::class, ); /** diff --git a/plugins/woocommerce/src/Internal/Admin/Analytics.php b/plugins/woocommerce/src/Internal/Admin/Analytics.php index b12ee48d96d..fc4d42378f6 100644 --- a/plugins/woocommerce/src/Internal/Admin/Analytics.php +++ b/plugins/woocommerce/src/Internal/Admin/Analytics.php @@ -49,7 +49,6 @@ class Analytics { * Hook into WooCommerce. */ public function __construct() { - add_filter( 'woocommerce_settings_features', array( $this, 'add_feature_toggle' ) ); add_action( 'update_option_' . self::TOGGLE_OPTION_NAME, array( $this, 'reload_page_on_toggle' ), 10, 2 ); add_action( 'woocommerce_settings_saved', array( $this, 'maybe_reload_page' ) ); @@ -66,24 +65,12 @@ class Analytics { /** * Add the feature toggle to the features settings. * + * @deprecated 7.0 The WooCommerce Admin features are now handled by the WooCommerce features engine (see the FeaturesController class). + * * @param array $features Feature sections. * @return array */ public static function add_feature_toggle( $features ) { - $description = __( - 'Enables WooCommerce Analytics', - 'woocommerce' - ); - - $features[] = array( - 'title' => __( 'Analytics', 'woocommerce' ), - 'desc' => $description, - 'id' => self::TOGGLE_OPTION_NAME, - 'type' => 'checkbox', - 'default' => 'yes', - 'class' => '', - ); - return $features; } diff --git a/plugins/woocommerce/src/Internal/DataStores/Orders/CustomOrdersTableController.php b/plugins/woocommerce/src/Internal/DataStores/Orders/CustomOrdersTableController.php index 544cb848d51..c023ef0ae9e 100644 --- a/plugins/woocommerce/src/Internal/DataStores/Orders/CustomOrdersTableController.php +++ b/plugins/woocommerce/src/Internal/DataStores/Orders/CustomOrdersTableController.php @@ -6,6 +6,7 @@ namespace Automattic\WooCommerce\Internal\DataStores\Orders; use Automattic\WooCommerce\Internal\BatchProcessing\BatchProcessingController; +use Automattic\WooCommerce\Internal\Features\FeaturesController; use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods; defined( 'ABSPATH' ) || exit; @@ -74,18 +75,16 @@ class CustomOrdersTableController { private $batch_processing_controller; /** - * Is the feature visible? + * The features controller to use. * - * @var bool + * @var FeaturesController */ - private $is_feature_visible; + private $features_controller; /** * Class constructor. */ public function __construct() { - $this->is_feature_visible = false; - $this->init_hooks(); } @@ -113,12 +112,19 @@ class CustomOrdersTableController { * @param DataSynchronizer $data_synchronizer The data synchronizer to use. * @param OrdersTableRefundDataStore $refund_data_store The refund data store to use. * @param BatchProcessingController $batch_processing_controller The batch processing controller to use. + * @param FeaturesController $features_controller The features controller instance to use. */ - final public function init( OrdersTableDataStore $data_store, DataSynchronizer $data_synchronizer, OrdersTableRefundDataStore $refund_data_store, BatchProcessingController $batch_processing_controller ) { + final public function init( + OrdersTableDataStore $data_store, + DataSynchronizer $data_synchronizer, + OrdersTableRefundDataStore $refund_data_store, + BatchProcessingController $batch_processing_controller, + FeaturesController $features_controller ) { $this->data_store = $data_store; $this->data_synchronizer = $data_synchronizer; $this->batch_processing_controller = $batch_processing_controller; $this->refund_data_store = $refund_data_store; + $this->features_controller = $features_controller; } /** @@ -127,21 +133,35 @@ class CustomOrdersTableController { * @return bool True if the feature is visible. */ public function is_feature_visible(): bool { - return $this->is_feature_visible; + return $this->features_controller->feature_is_enabled( 'custom_order_tables' ); } /** * Makes the feature visible, so that dedicated entries will be added to the debug tools page. + * + * This method shouldn't be used anymore, see the FeaturesController class. */ public function show_feature() { - $this->is_feature_visible = true; + $class_and_method = ( new \ReflectionClass( $this ) )->getShortName() . '::' . __FUNCTION__; + wc_doing_it_wrong( + $class_and_method, + __( "${class_and_method}: The visibility of the custom orders table feature is now handled by the WooCommerce features engine. See the FeaturesController class, or go to WooCommerce - Settings - Advanced - Features.", 'woocommerce' ), + '7.0' + ); } /** * Hides the feature, so that no entries will be added to the debug tools page. + * + * This method shouldn't be used anymore, see the FeaturesController class. */ public function hide_feature() { - $this->is_feature_visible = false; + $class_and_method = ( new \ReflectionClass( $this ) )->getShortName() . '::' . __FUNCTION__; + wc_doing_it_wrong( + $class_and_method, + __( "${class_and_method}: The visibility of the custom orders table feature is now handled by the WooCommerce features engine. See the FeaturesController class, or go to WooCommerce - Settings - Advanced - Features.", 'woocommerce' ), + '7.0' + ); } /** diff --git a/plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/FeaturesServiceProvider.php b/plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/FeaturesServiceProvider.php new file mode 100644 index 00000000000..dcf05156662 --- /dev/null +++ b/plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/FeaturesServiceProvider.php @@ -0,0 +1,29 @@ +share( FeaturesController::class )->addArgument( LegacyProxy::class ); + } +} diff --git a/plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/OrdersDataStoreServiceProvider.php b/plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/OrdersDataStoreServiceProvider.php index 53bc12ea33f..71eac01e840 100644 --- a/plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/OrdersDataStoreServiceProvider.php +++ b/plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/OrdersDataStoreServiceProvider.php @@ -15,6 +15,7 @@ use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer; use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController; use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore; use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStoreMeta; +use Automattic\WooCommerce\Internal\Features\FeaturesController; use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil; /** @@ -51,6 +52,7 @@ class OrdersDataStoreServiceProvider extends AbstractServiceProvider { DataSynchronizer::class, OrdersTableRefundDataStore::class, BatchProcessingController::class, + FeaturesController::class, ) ); if ( Constants::is_defined( 'WP_CLI' ) && WP_CLI ) { diff --git a/plugins/woocommerce/src/Internal/Features/FeaturesController.php b/plugins/woocommerce/src/Internal/Features/FeaturesController.php new file mode 100644 index 00000000000..63cc9ee7ca2 --- /dev/null +++ b/plugins/woocommerce/src/Internal/Features/FeaturesController.php @@ -0,0 +1,507 @@ + array( + 'name' => __( 'Analytics', 'woocommerce' ), + 'description' => __( 'Enables WooCommerce Analytics', 'woocommerce' ), + 'is_experimental' => false, + ), + 'new_navigation' => array( + 'name' => __( 'Navigation', 'woocommerce' ), + 'description' => __( 'Adds the new WooCommerce navigation experience to the dashboard', 'woocommerce' ), + 'is_experimental' => false, + ), + 'custom_order_tables' => array( + 'name' => __( 'Custom order tables', 'woocommerce' ), + 'description' => __( 'Enable the custom orders tables feature (still in development)', 'woocommerce' ), + 'is_experimental' => true, + ), + ); + + $this->init_features( $features ); + + foreach ( array_keys( $this->features ) as $feature_id ) { + $this->compatibility_info_by_feature[ $feature_id ] = array( + 'compatible' => array(), + 'incompatible' => array(), + ); + } + + self::add_filter( 'updated_option', array( $this, 'process_updated_option' ), 999, 3 ); + self::add_filter( 'added_option', array( $this, 'process_added_option' ), 999, 3 ); + self::add_filter( 'woocommerce_get_sections_advanced', array( $this, 'add_features_section' ), 10, 1 ); + self::add_filter( 'woocommerce_get_settings_advanced', array( $this, 'add_feature_settings' ), 10, 2 ); + } + + /** + * Initialize the class according to the existing features. + * + * @param array $features Information about the existing features. + */ + private function init_features( array $features ) { + $this->compatibility_info_by_plugin = array(); + $this->compatibility_info_by_feature = array(); + + $this->features = $features; + + foreach ( array_keys( $this->features ) as $feature_id ) { + $this->compatibility_info_by_feature[ $feature_id ] = array( + 'compatible' => array(), + 'incompatible' => array(), + ); + } + } + + /** + * Initialize the class instance. + * + * @internal + * + * @param LegacyProxy $proxy The instance of LegacyProxy to use. + */ + final public function init( LegacyProxy $proxy ) { + $this->proxy = $proxy; + } + + /** + * Get all the existing WooCommerce features. + * + * Returns an associative array where keys are unique feature ids + * and values are arrays with these keys: + * + * - name (string) + * - description (string) + * - is_experimental (bool) + * - is_enabled (bool) (only if $include_enabled_info is passed as true) + * + * @param bool $include_experimental Include also experimental/work in progress features in the list. + * @param bool $include_enabled_info True to include the 'is_enabled' field in the returned features info. + * @returns array An array of information about existing features. + */ + public function get_features( bool $include_experimental = false, bool $include_enabled_info = false ): array { + $features = $this->features; + + if ( ! $include_experimental ) { + $features = array_filter( + $features, + function( $feature ) { + return ! $feature['is_experimental']; + } + ); + } + + if ( $include_enabled_info ) { + foreach ( array_keys( $features ) as $feature_id ) { + $is_enabled = $this->feature_is_enabled( $feature_id ); + $features[ $feature_id ]['is_enabled'] = $is_enabled; + } + } + + return $features; + } + + /** + * Check if a given feature is currently enabled. + * + * @param string $feature_id Unique feature id. + * @return bool True if the feature is enabled, false if not or if the feature doesn't exist. + */ + public function feature_is_enabled( string $feature_id ): bool { + return $this->feature_exists( $feature_id ) && 'yes' === get_option( $this->feature_enable_option_name( $feature_id ) ); + } + + /** + * Change the enabled/disabled status of a feature. + * + * @param string $feature_id Unique feature id. + * @param bool $enable True to enable the feature, false to disable it. + * @return bool True on success, false if feature doesn't exist or the new value is the same as the old value. + */ + public function change_feature_enable( string $feature_id, bool $enable ): bool { + if ( ! $this->feature_exists( $feature_id ) ) { + return false; + } + + return update_option( $this->feature_enable_option_name( $feature_id ), $enable ? 'yes' : 'no' ); + } + + /** + * Declare (in)compatibility with a given feature for a given plugin. + * + * This method MUST be executed from inside a handler for the 'before_woocommerce_init' hook. + * + * The plugin name is expected to be in the form 'directory/file.php' and be one of the keys + * of the array returned by 'get_plugins', but this won't be checked. Plugins are expected to use + * FeaturesUtil::declare_compatibility instead, passing the full plugin file path instead of the plugin name. + * + * @param string $feature_id Unique feature id. + * @param string $plugin_name Plugin name, in the form 'directory/file.php'. + * @param bool $positive_compatibility True if the plugin declares being compatible with the feature, false if it declares being incompatible. + * @return bool True on success, false on error (feature doesn't exist or not inside the required hook). + * @throws \Exception A plugin attempted to declare itself as compatible and incompatible with a given feature at the same time. + */ + public function declare_compatibility( string $feature_id, string $plugin_name, bool $positive_compatibility = true ): bool { + if ( ! $this->proxy->call_function( 'doing_action', 'before_woocommerce_init' ) ) { + $class_and_method = ( new \ReflectionClass( $this ) )->getShortName() . '::' . __FUNCTION__; + /* translators: 1: class::method 2: before_woocommerce_init */ + $this->proxy->call_function( 'wc_doing_it_wrong', $class_and_method, sprintf( __( '%1$s should be called inside the %2$s action.', 'woocommerce' ), $class_and_method, 'before_woocommerce_init' ), '7.0' ); + return false; + } + + if ( ! $this->feature_exists( $feature_id ) ) { + return false; + } + + // Register compatibility by plugin. + + ArrayUtil::ensure_key_is_array( $this->compatibility_info_by_plugin, $plugin_name ); + + $key = $positive_compatibility ? 'compatible' : 'incompatible'; + $opposite_key = $positive_compatibility ? 'incompatible' : 'compatible'; + ArrayUtil::ensure_key_is_array( $this->compatibility_info_by_plugin[ $plugin_name ], $key ); + ArrayUtil::ensure_key_is_array( $this->compatibility_info_by_plugin[ $plugin_name ], $opposite_key ); + + if ( in_array( $feature_id, $this->compatibility_info_by_plugin[ $plugin_name ][ $opposite_key ], true ) ) { + throw new \Exception( "Plugin $plugin_name is trying to declare itself as $key with the '$feature_id' feature, but it already declared itself as $opposite_key" ); + } + + if ( ! in_array( $feature_id, $this->compatibility_info_by_plugin[ $plugin_name ][ $key ], true ) ) { + $this->compatibility_info_by_plugin[ $plugin_name ][ $key ][] = $feature_id; + } + + // Register compatibility by feature. + + $key = $positive_compatibility ? 'compatible' : 'incompatible'; + + if ( ! in_array( $plugin_name, $this->compatibility_info_by_feature[ $feature_id ][ $key ], true ) ) { + $this->compatibility_info_by_feature[ $feature_id ][ $key ][] = $plugin_name; + } + + return true; + } + + /** + * Check whether a feature exists with a given id. + * + * @param string $feature_id The feature id to check. + * @return bool True if the feature exists. + */ + private function feature_exists( string $feature_id ): bool { + return isset( $this->features[ $feature_id ] ); + } + + /** + * Get the ids of the features that a certain plugin has declared compatibility for. + * + * This method can't be called before the 'woocommerce_init' hook is fired. + * + * @param string $plugin_name Plugin name, in the form 'directory/file.php'. + * @return array An array having a 'compatible' and an 'incompatible' key, each holding an array of feature ids. + */ + public function get_compatible_features_for_plugin( string $plugin_name ) : array { + $this->verify_did_woocommerce_init( __FUNCTION__ ); + + if ( ! isset( $this->compatibility_info_by_plugin[ $plugin_name ] ) ) { + return array( + 'compatible' => array(), + 'incompatible' => array(), + ); + } + + return $this->compatibility_info_by_plugin[ $plugin_name ]; + } + + /** + * Get the names of the plugins that have been declared compatible or incompatible with a given feature. + * + * @param string $feature_id Feature id. + * @return array An array having a 'compatible' and an 'incompatible' key, each holding an array of plugin names. + */ + public function get_compatible_plugins_for_feature( string $feature_id ) : array { + $this->verify_did_woocommerce_init( __FUNCTION__ ); + + if ( ! $this->feature_exists( $feature_id ) ) { + return array( + 'compatible' => array(), + 'incompatible' => array(), + ); + } + + return $this->compatibility_info_by_feature[ $feature_id ]; + } + + /** + * Check if the 'woocommerce_init' has run or is running, do a 'wc_doing_it_wrong' if not. + * + * @param string $function Name of the invoking method. + */ + private function verify_did_woocommerce_init( string $function ) { + if ( ! $this->proxy->call_function( 'did_action', 'woocommerce_init' ) && + ! $this->proxy->call_function( 'doing_action', 'woocommerce_init' ) ) { + $class_and_method = ( new \ReflectionClass( $this ) )->getShortName() . '::' . $function; + /* translators: 1: class::method 2: plugins_loaded */ + $this->proxy->call_function( 'wc_doing_it_wrong', $class_and_method, sprintf( __( '%1$s should not be called before the %2$s action.', 'woocommerce' ), $class_and_method, 'woocommerce_init' ), '7.0' ); + } + } + + /** + * Get the name of the option that enables/disables a given feature. + * Note that it doesn't check if the feature actually exists. + * + * @param string $feature_id The id of the feature. + * @return string The option that enables or disables the feature. + */ + public function feature_enable_option_name( string $feature_id ): string { + if ( 'analytics' === $feature_id ) { + return Analytics::TOGGLE_OPTION_NAME; + } elseif ( 'new_navigation' === $feature_id ) { + return Init::TOGGLE_OPTION_NAME; + } + + return "woocommerce_feature_${feature_id}_enabled"; + } + + /** + * Handler for the 'added_option' hook. + * + * It fires FEATURE_ENABLED_CHANGED_ACTION when a feature is enabled or disabled. + * + * @param string $option The option that has been created. + * @param mixed $value The value of the option. + */ + private function process_added_option( string $option, $value ) { + $this->process_updated_option( $option, false, $value ); + } + + /** + * Handler for the 'updated_option' hook. + * + * It fires FEATURE_ENABLED_CHANGED_ACTION when a feature is enabled or disabled. + * + * @param string $option The option that has been modified. + * @param mixed $old_value The old value of the option. + * @param mixed $value The new value of the option. + */ + private function process_updated_option( string $option, $old_value, $value ) { + $matches = array(); + $success = preg_match( '/^woocommerce_feature_([a-zA-Z0-9_]+)_enabled$/', $option, $matches ); + + if ( ! $success ) { + return; + } + + if ( $value === $old_value ) { + return; + } + + $feature_id = $matches[1]; + + /** + * Action triggered when a feature is enabled or disabled (the value of the corresponding setting option is changed). + * + * @param string $feature_id The id of the feature. + * @param bool $enabled True if the feature has been enabled, false if it has been disabled. + * + * @since 7.0.0 + */ + do_action( self::FEATURE_ENABLED_CHANGED_ACTION, $feature_id, 'yes' === $value ); + } + + /** + * Handler for the 'woocommerce_get_sections_advanced' hook, + * it adds the "Features" section to the advanced settings page. + * + * @param array $sections The original sections array. + * @return array The updated sections array. + */ + private function add_features_section( $sections ) { + if ( ! isset( $sections['features'] ) ) { + $sections['features'] = __( 'Features', 'woocommerce' ); + } + return $sections; + } + + /** + * Handler for the 'woocommerce_get_settings_advanced' hook, + * it adds the settings UI for all the existing features. + * + * Note that the settings added via the 'woocommerce_settings_features' hook will be + * displayed in the non-experimental features section. + * + * @param array $settings The existing settings for the corresponding settings section. + * @param string $current_section The section to get the settings for. + * @return array The updated settings array. + */ + private function add_feature_settings( $settings, $current_section ): array { + if ( 'features' !== $current_section ) { + return $settings; + } + + // phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment + /** + * Filter allowing WooCommerce Admin to be disabled. + * + * @param bool $disabled False. + */ + $admin_features_disabled = apply_filters( 'woocommerce_admin_disabled', false ); + // phpcs:enable WooCommerce.Commenting.CommentHooks.MissingSinceComment + + $feature_settings = + array( + array( + 'title' => __( 'Features', 'woocommerce' ), + 'type' => 'title', + 'desc' => __( 'Start using new features that are being progressively rolled out to improve the store management experience.', 'woocommerce' ), + 'id' => 'features_options', + ), + ); + + $features = $this->get_features( true ); + + $feature_ids = array_keys( $features ); + $experimental_feature_ids = array_filter( + $feature_ids, + function( $feature_id ) use ( $features ) { + return $features[ $feature_id ]['is_experimental']; + } + ); + $mature_feature_ids = array_diff( $feature_ids, $experimental_feature_ids ); + $feature_ids = array_merge( $mature_feature_ids, array( 'mature_features_end' ), $experimental_feature_ids ); + + foreach ( $feature_ids as $id ) { + if ( 'mature_features_end' === $id ) { + // phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment + /** + * Filter allowing to add additional settings to the WooCommerce Advanced - Features settings page. + * + * @param bool $disabled False. + */ + $additional_features = apply_filters( 'woocommerce_settings_features', $features ); + // phpcs:enable WooCommerce.Commenting.CommentHooks.MissingSinceComment + + $feature_settings = array_merge( $feature_settings, $additional_features ); + + if ( ! empty( $experimental_feature_ids ) ) { + $feature_settings[] = array( + 'type' => 'sectionend', + 'id' => 'features_options', + ); + + $feature_settings[] = array( + 'title' => __( 'Experimental features', 'woocommerce' ), + 'type' => 'title', + 'desc' => __( 'These features are either experimental or incomplete, enable them at your own risk!', 'woocommerce' ), + 'id' => 'experimental_features_options', + ); + } + continue; + } + + $feature_settings[] = $this->get_setting_for_feature( $id, $features[ $id ], $admin_features_disabled ); + } + + $feature_settings[] = array( + 'type' => 'sectionend', + 'id' => empty( $experimental_feature_ids ) ? 'features_options' : 'experimental_features_options', + ); + + return $feature_settings; + } + + /** + * Get the parameters to display the setting enable/disable UI for a given feature. + * + * @param string $feature_id The feature id. + * @param array $feature The feature parameters, as returned by get_features. + * @param bool $admin_features_disabled True if admin features have been disabled via 'woocommerce_admin_disabled' filter. + * @return array The parameters to add to the settings array. + */ + private function get_setting_for_feature( string $feature_id, array $feature, bool $admin_features_disabled ): array { + $description = $feature['description']; + $disabled = false; + $desc_tip = ''; + + if ( ( 'analytics' === $feature_id || 'new_navigation' === $feature_id ) && $admin_features_disabled ) { + $disabled = true; + $desc_tip = __( 'WooCommerce Admin has been disabled', 'woocommerce' ); + } elseif ( 'new_navigation' === $feature_id ) { + $needs_update = version_compare( get_bloginfo( 'version' ), '5.6', '<' ); + if ( $needs_update && current_user_can( 'update_core' ) && current_user_can( 'update_php' ) ) { + $update_text = sprintf( + // translators: 1: line break tag, 2: open link to WordPress update link, 3: close link tag. + __( '%1$s %2$sUpdate WordPress to enable the new navigation%3$s', 'woocommerce' ), + '
', + '', + '' + ); + $description .= $update_text; + $disabled = true; + } + } + + return array( + 'title' => $feature['name'], + 'desc' => $description, + 'type' => 'checkbox', + 'id' => $this->feature_enable_option_name( $feature_id ), + 'class' => $disabled ? 'disabled' : '', + 'desc_tip' => $desc_tip, + ); + } +} diff --git a/plugins/woocommerce/src/Utilities/ArrayUtil.php b/plugins/woocommerce/src/Utilities/ArrayUtil.php index 7c758003d97..ade81093b72 100644 --- a/plugins/woocommerce/src/Utilities/ArrayUtil.php +++ b/plugins/woocommerce/src/Utilities/ArrayUtil.php @@ -184,5 +184,28 @@ class ArrayUtil { $array[] = $value; return true; } + + /** + * Ensure that an associative array has a given key, and if not, set the key to an empty array. + * + * @param array $array The array to check. + * @param string $key The key to check. + * @param bool $throw_if_existing_is_not_array If true, an exception will be thrown if the key already exists in the array but the value is not an array. + * @return bool True if the key has been added to the array, false if not (the key already existed). + * @throws \Exception The key already exists in the array but the value is not an array. + */ + public static function ensure_key_is_array( array &$array, string $key, bool $throw_if_existing_is_not_array = false ): bool { + if ( ! isset( $array[ $key ] ) ) { + $array[ $key ] = array(); + return true; + } + + if ( $throw_if_existing_is_not_array && ! is_array( $array[ $key ] ) ) { + $type = is_object( $array[ $key ] ) ? get_class( $array[ $key ] ) : gettype( $array[ $key ] ); + throw new \Exception( "Array key exists but it's not an array, it's a {$type}" ); + } + + return false; + } } diff --git a/plugins/woocommerce/src/Utilities/FeaturesUtil.php b/plugins/woocommerce/src/Utilities/FeaturesUtil.php new file mode 100644 index 00000000000..800490330e1 --- /dev/null +++ b/plugins/woocommerce/src/Utilities/FeaturesUtil.php @@ -0,0 +1,88 @@ +get( FeaturesController::class )->get_features( $include_experimental, $include_enabled_info ); + } + + /** + * Check if a given feature is currently enabled. + * + * @param string $feature_id Unique feature id. + * @return bool True if the feature is enabled, false if not or if the feature doesn't exist. + */ + public static function feature_is_enabled( string $feature_id ): bool { + return wc_get_container()->get( FeaturesController::class )->feature_is_enabled( $feature_id ); + } + + /** + * Declare (in)compatibility with a given feature for a given plugin. + * + * This method MUST be executed from inside a handler for the 'before_woocommerce_init' hook and + * SHOULD be executed from the main plugin file passing __FILE__ for the $plugin_file argument. + * + * @param string $feature_id Unique feature id. + * @param string $plugin_file The full plugin file path. + * @param bool $positive_compatibility True if the plugin declares being compatible with the feature, false if it declares being incompatible. + * @return bool True on success, false on error (feature doesn't exist or not inside the required hook). + * @throws \Exception A plugin attempted to declare itself as compatible and incompatible with a given feature at the same time. + */ + public static function declare_compatibility( string $feature_id, string $plugin_file, bool $positive_compatibility = true ): bool { + $plugin_name = StringUtil::plugin_name_from_plugin_file( $plugin_file ); + + if ( ! isset( get_plugins()[ $plugin_name ] ) ) { + throw new \Exception( "FeaturesUtil::declare_compatibility: ${plugin_name} is not a known WordPress plugin." ); + } + + return wc_get_container()->get( FeaturesController::class )->declare_compatibility( $feature_id, $plugin_name, $positive_compatibility ); + } + + /** + * Get the ids of the features that a certain plugin has declared compatibility for. + * + * This method can't be called before the 'woocommerce_init' hook is fired. + * + * @param string $plugin_name Plugin name, in the form 'directory/file.php'. + * @return array An array having a 'compatible' and an 'incompatible' key, each holding an array of plugin ids. + */ + public static function get_compatible_features_for_plugin( string $plugin_name ): array { + return wc_get_container()->get( FeaturesController::class )->get_compatible_features_for_plugin( $plugin_name ); + } + + /** + * Get the names of the plugins that have been declared compatible or incompatible with a given feature. + * + * @param string $feature_id Feature id. + * @return array An array having a 'compatible' and an 'incompatible' key, each holding an array of plugin names. + */ + public static function get_compatible_plugins_for_feature( string $feature_id ): array { + return wc_get_container()->get( FeaturesController::class )->get_compatible_plugins_for_feature( $feature_id ); + } +} diff --git a/plugins/woocommerce/src/Utilities/StringUtil.php b/plugins/woocommerce/src/Utilities/StringUtil.php index 5e6fefbdf15..d120bdf6ac6 100644 --- a/plugins/woocommerce/src/Utilities/StringUtil.php +++ b/plugins/woocommerce/src/Utilities/StringUtil.php @@ -73,4 +73,14 @@ final class StringUtil { return false !== stripos( $string, $contained ); } } + + /** + * Get the name of a plugin in the form 'directory/file.php', as in the keys of the array returned by 'get_plugins'. + * + * @param string $plugin_file_path The path of the main plugin file (can be passed as __FILE__ from the plugin itself). + * @return string The name of the plugin in the form 'directory/file.php'. + */ + public static function plugin_name_from_plugin_file( string $plugin_file_path ): string { + return basename( dirname( $plugin_file_path ) ) . DIRECTORY_SEPARATOR . basename( $plugin_file_path ); + } } diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Helpers/OrderHelper.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Helpers/OrderHelper.php index f27250b67b0..60edc223091 100644 --- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Helpers/OrderHelper.php +++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Helpers/OrderHelper.php @@ -13,6 +13,7 @@ defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController; use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer; use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore; +use Automattic\WooCommerce\Internal\Features\FeaturesController; use WC_Mock_Payment_Gateway; use WC_Order; use WC_Product; @@ -140,9 +141,8 @@ class OrderHelper { * Helper method to drop custom tables if present. */ public static function delete_order_custom_tables() { - $order_table_controller = wc_get_container() - ->get( CustomOrdersTableController::class ); - $order_table_controller->show_feature(); + $features_controller = wc_get_container()->get( Featurescontroller::class ); + $features_controller->change_feature_enable( 'custom_order_tables', true ); $synchronizer = wc_get_container() ->get( DataSynchronizer::class ); if ( $synchronizer->check_orders_table_exists() ) { @@ -154,11 +154,10 @@ class OrderHelper { * Helper method to create custom tables if not present. */ public static function create_order_custom_table_if_not_exist() { - $order_table_controller = wc_get_container() - ->get( CustomOrdersTableController::class ); - $order_table_controller->show_feature(); - $synchronizer = wc_get_container() - ->get( DataSynchronizer::class ); + $features_controller = wc_get_container()->get( Featurescontroller::class ); + $features_controller->change_feature_enable( 'custom_order_tables', true ); + + $synchronizer = wc_get_container()->get( DataSynchronizer::class ); if ( ! $synchronizer->check_orders_table_exists() ) { $synchronizer->create_database_tables(); } diff --git a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/DataSynchronizerTests.php b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/DataSynchronizerTests.php index 06fe185b24c..e36edb8010b 100644 --- a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/DataSynchronizerTests.php +++ b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/DataSynchronizerTests.php @@ -2,6 +2,7 @@ use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController; use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer; +use Automattic\WooCommerce\Internal\Features\FeaturesController; use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper; /** @@ -14,11 +15,6 @@ class DataSynchronizerTests extends WC_Unit_Test_Case { */ private $sut; - /** - * @var CustomOrdersTableController - */ - private $cot_controller; - /** * Initializes system under test. */ @@ -29,9 +25,9 @@ class DataSynchronizerTests extends WC_Unit_Test_Case { remove_filter( 'query', array( $this, '_drop_temporary_tables' ) ); OrderHelper::delete_order_custom_tables(); // We need this since non-temporary tables won't drop automatically. OrderHelper::create_order_custom_table_if_not_exist(); - $this->sut = wc_get_container()->get( DataSynchronizer::class ); - $this->cot_controller = wc_get_container()->get( CustomOrdersTableController::class ); - $this->cot_controller->show_feature(); + $this->sut = wc_get_container()->get( DataSynchronizer::class ); + $features_controller = wc_get_container()->get( Featurescontroller::class ); + $features_controller->change_feature_enable( 'custom_order_tables', true ); } /** diff --git a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreRestOrdersControllerTests.php b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreRestOrdersControllerTests.php index 2ccd6a999d2..f78bc841079 100644 --- a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreRestOrdersControllerTests.php +++ b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreRestOrdersControllerTests.php @@ -2,6 +2,7 @@ use Automattic\WooCommerce\Database\Migrations\CustomOrderTable\PostsToOrdersMigrationController; use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController; use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore; +use Automattic\WooCommerce\Internal\Features\FeaturesController; use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper; if ( ! class_exists( 'WC_REST_Orders_Controller_Tests' ) ) { @@ -79,12 +80,14 @@ class OrdersTableDataStoreRestOrdersControllerTests extends \WC_REST_Orders_Cont * @return void */ private function toggle_cot( bool $enabled ): void { - $controller = wc_get_container()->get( CustomOrdersTableController::class )->show_feature(); + $features_controller = wc_get_container()->get( Featurescontroller::class ); + $features_controller->change_feature_enable( 'custom_order_tables', true ); + update_option( CustomOrdersTableController::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, wc_bool_to_string( $enabled ) ); // Confirm things are really correct. $wc_data_store = WC_Data_Store::load( 'order' ); - assert( $enabled === is_a( $wc_data_store->get_current_class_name(), OrdersTableDataStore::class, true ) ); + assert( is_a( $wc_data_store->get_current_class_name(), OrdersTableDataStore::class, true ) === $enabled ); } } diff --git a/plugins/woocommerce/tests/php/src/Internal/Features/FeaturesControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/Features/FeaturesControllerTest.php new file mode 100644 index 00000000000..053b6efda74 --- /dev/null +++ b/plugins/woocommerce/tests/php/src/Internal/Features/FeaturesControllerTest.php @@ -0,0 +1,557 @@ +reset_container_resolutions(); + $this->reset_legacy_proxy_mocks(); + + $features = array( + 'mature1' => array( + 'name' => 'Mature feature 1', + 'description' => 'The mature feature number 1', + 'is_experimental' => false, + ), + 'mature2' => array( + 'name' => 'Mature feature 2', + 'description' => 'The mature feature number 1', + 'is_experimental' => false, + ), + 'experimental1' => array( + 'name' => 'Experimental feature 1', + 'description' => 'The experimental feature number 1', + 'is_experimental' => true, + ), + 'experimental2' => array( + 'name' => 'Experimental feature 2', + 'description' => 'The experimental feature number 2', + 'is_experimental' => true, + ), + ); + + $this->sut = $this->get_instance_of( FeaturesController::class ); + $init_features_method = new \ReflectionMethod( $this->sut, 'init_features' ); + $init_features_method->setAccessible( true ); + $init_features_method->invoke( $this->sut, $features ); + + delete_option( 'woocommerce_feature_mature1_enabled' ); + delete_option( 'woocommerce_feature_mature2_enabled' ); + delete_option( 'woocommerce_feature_experimental1_enabled' ); + delete_option( 'woocommerce_feature_experimental2_enabled' ); + + remove_all_filters( FeaturesController::FEATURE_ENABLED_CHANGED_ACTION ); + } + + /** + * @testdox 'get_features' returns existing non-experimental features without enabling information if requested to do so. + */ + public function test_get_features_not_including_experimental_not_including_values() { + $actual = $this->sut->get_features( false, false ); + + $expected = array( + 'mature1' => array( + 'name' => 'Mature feature 1', + 'description' => 'The mature feature number 1', + 'is_experimental' => false, + ), + 'mature2' => array( + 'name' => 'Mature feature 2', + 'description' => 'The mature feature number 1', + 'is_experimental' => false, + ), + ); + + $this->assertEquals( $expected, $actual ); + } + + /** + * @testdox 'get_features' returns all existing features without enabling information if requested to do so. + */ + public function test_get_features_including_experimental_not_including_values() { + $actual = $this->sut->get_features( true, false ); + + $expected = array( + 'mature1' => array( + 'name' => 'Mature feature 1', + 'description' => 'The mature feature number 1', + 'is_experimental' => false, + ), + 'mature2' => array( + 'name' => 'Mature feature 2', + 'description' => 'The mature feature number 1', + 'is_experimental' => false, + ), + 'experimental1' => array( + 'name' => 'Experimental feature 1', + 'description' => 'The experimental feature number 1', + 'is_experimental' => true, + ), + 'experimental2' => array( + 'name' => 'Experimental feature 2', + 'description' => 'The experimental feature number 2', + 'is_experimental' => true, + ), + ); + + $this->assertEquals( $expected, $actual ); + } + + /** + * @testdox 'get_features' returns all existing features with enabling information if requested to do so. + */ + public function test_get_features_including_experimental_and_values() { + update_option( 'woocommerce_feature_mature1_enabled', 'yes' ); + update_option( 'woocommerce_feature_mature2_enabled', 'no' ); + update_option( 'woocommerce_feature_experimental1_enabled', 'yes' ); + // No option for experimental2. + + $actual = $this->sut->get_features( true, true ); + + $expected = array( + 'mature1' => array( + 'name' => 'Mature feature 1', + 'description' => 'The mature feature number 1', + 'is_experimental' => false, + 'is_enabled' => true, + ), + 'mature2' => array( + 'name' => 'Mature feature 2', + 'description' => 'The mature feature number 1', + 'is_experimental' => false, + 'is_enabled' => false, + ), + 'experimental1' => array( + 'name' => 'Experimental feature 1', + 'description' => 'The experimental feature number 1', + 'is_experimental' => true, + 'is_enabled' => true, + ), + 'experimental2' => array( + 'name' => 'Experimental feature 2', + 'description' => 'The experimental feature number 2', + 'is_experimental' => true, + 'is_enabled' => false, + ), + ); + + $this->assertEquals( $expected, $actual ); + } + + /** + * @testdox 'feature_is_enabled' returns whether a feature is enabled, and returns false for invalid feature ids. + * + * @testWith ["mature1", true] + * ["mature2", false] + * ["experimental1", false] + * ["NOT_EXISTING", false] + * + * @param string $feature_id Feature id to check. + * @param bool $expected_to_be_enabled Expected result from the method. + */ + public function test_feature_is_enabled( $feature_id, $expected_to_be_enabled ) { + update_option( 'woocommerce_feature_mature1_enabled', 'yes' ); + update_option( 'woocommerce_feature_mature2_enabled', 'no' ); + // No option for experimental1. + + $this->assertEquals( $expected_to_be_enabled, $this->sut->feature_is_enabled( $feature_id ) ); + } + + /** + * @testdox 'change_feature_enable' does nothing and returns false for an invalid feature id. + */ + public function test_change_feature_enable_for_non_existing_feature() { + $result = $this->sut->change_feature_enable( 'NON_EXISTING', true ); + $this->assertFalse( $result ); + } + + /** + * @testdox 'change_feature_enabled' works as expected with and without previous values for the feature enable options. + * + * @testWith [null, false, true, false, false] + * [null, true, true, false, true] + * ["no", false, false, false, false] + * ["no", true, true, false, true] + * ["yes", false, true, true, false] + * ["yes", true, false, true, true] + * + * @param string|null $previous_value The previous value of the feature enable option. + * @param bool $enable Whether the feature will be enabled or disabled. + * @param bool $expected_result Expected value to be returned by 'change_feature_enable'. + * @param bool $expected_previous_enabled Expected value to be returned by 'feature_is_enabled' before the feature status is changed. + * @param bool $expected_new_enabled Expected value to be returned by 'feature_is_enabled' after the feature status is changed. + */ + public function test_change_feature_enable( $previous_value, $enable, $expected_result, $expected_previous_enabled, $expected_new_enabled ) { + if ( $previous_value ) { + update_option( 'woocommerce_feature_mature1_enabled', $previous_value ); + } + + $result = $this->sut->feature_is_enabled( 'mature1' ); + $this->assertEquals( $expected_previous_enabled, $result ); + + $result = $this->sut->change_feature_enable( 'mature1', $enable ); + $this->assertEquals( $result, $expected_result ); + + $result = $this->sut->feature_is_enabled( 'mature1' ); + $this->assertEquals( $expected_new_enabled, $result ); + } + + /** + * @testdox 'declare_compatibility' fails when invoked from outside the 'before_woocommerce_init' action. + */ + public function test_declare_compatibility_outside_before_woocommerce_init_hook() { + $function = null; + $message = null; + $version = null; + + $this->register_legacy_proxy_function_mocks( + array( + 'wc_doing_it_wrong' => function( $f, $m, $v ) use ( &$function, &$message, &$version ) { + $function = $f; + $message = $m; + $version = $v; + }, + ) + ); + + $result = $this->sut->declare_compatibility( 'mature1', 'the_plugin' ); + $this->assertFalse( $result ); + + $this->assertEquals( 'FeaturesController::declare_compatibility', $function ); + $this->assertEquals( 'FeaturesController::declare_compatibility should be called inside the before_woocommerce_init action.', $message ); + $this->assertEquals( '7.0', $version ); + } + + /** + * @testdox 'declare_compatibility' returns false for invalid feature ids. + */ + public function test_declare_compatibility_for_non_existing_feature() { + $this->simulate_inside_before_woocommerce_init_hook(); + + $result = $this->sut->declare_compatibility( 'NON_EXISTING', 'the_plugin' ); + $this->assertFalse( $result ); + } + + /** + * @testdox 'declare_compatibility' registers internally the proper per-plugin information. + */ + public function test_declare_compatibility_by_plugin() { + $this->simulate_inside_before_woocommerce_init_hook(); + + $result = $this->sut->declare_compatibility( 'mature1', 'the_plugin' ); + $this->assertTrue( $result ); + $result = $this->sut->declare_compatibility( 'experimental1', 'the_plugin' ); + $this->assertTrue( $result ); + $result = $this->sut->declare_compatibility( 'experimental2', 'the_plugin', false ); + $this->assertTrue( $result ); + // Duplicate declaration is ok:. + $result = $this->sut->declare_compatibility( 'experimental2', 'the_plugin', false ); + $this->assertTrue( $result ); + + $compatibility_info_prop = new \ReflectionProperty( $this->sut, 'compatibility_info_by_plugin' ); + $compatibility_info_prop->setAccessible( true ); + $compatibility_info = $compatibility_info_prop->getValue( $this->sut ); + + $expected = array( + 'the_plugin' => array( + 'compatible' => array( + 'mature1', + 'experimental1', + ), + 'incompatible' => array( + 'experimental2', + ), + ), + ); + + $this->assertEquals( $expected, $compatibility_info ); + } + + /** + * @testdox 'declare_compatibility' registers internally the proper per-feature information. + */ + public function test_declare_compatibility_by_feature() { + $this->simulate_inside_before_woocommerce_init_hook(); + + $result = $this->sut->declare_compatibility( 'mature1', 'the_plugin_1' ); + $this->assertTrue( $result ); + $result = $this->sut->declare_compatibility( 'mature1', 'the_plugin_2' ); + $this->assertTrue( $result ); + $result = $this->sut->declare_compatibility( 'mature1', 'the_plugin_3', false ); + $this->assertTrue( $result ); + $result = $this->sut->declare_compatibility( 'experimental1', 'the_plugin_1', false ); + $this->assertTrue( $result ); + $result = $this->sut->declare_compatibility( 'experimental2', 'the_plugin_2', true ); + $this->assertTrue( $result ); + + $compatibility_info_prop = new \ReflectionProperty( $this->sut, 'compatibility_info_by_feature' ); + $compatibility_info_prop->setAccessible( true ); + $compatibility_info = $compatibility_info_prop->getValue( $this->sut ); + + $expected = array( + 'mature1' => array( + 'compatible' => array( + 'the_plugin_1', + 'the_plugin_2', + ), + 'incompatible' => array( + 'the_plugin_3', + ), + ), + 'mature2' => array( + 'compatible' => array(), + 'incompatible' => array(), + ), + 'experimental1' => array( + 'compatible' => array(), + 'incompatible' => array( + 'the_plugin_1', + ), + ), + 'experimental2' => array( + 'compatible' => array( + 'the_plugin_2', + ), + 'incompatible' => array(), + ), + ); + + $this->assertEquals( $expected, $compatibility_info ); + } + + /** + * @testdox 'declare_compatibility' throws when a plugin declares itself as both compatible and incompatible with a given feature. + */ + public function test_declare_compatibility_and_incompatibility_for_the_same_plugin() { + $this->simulate_inside_before_woocommerce_init_hook(); + + $this->ExpectException( \Exception::class ); + $this->ExpectExceptionMessage( "Plugin the_plugin is trying to declare itself as incompatible with the 'mature1' feature, but it already declared itself as compatible" ); + + $this->sut->declare_compatibility( 'mature1', 'the_plugin', true ); + $this->sut->declare_compatibility( 'mature1', 'the_plugin', false ); + } + + /** + * @testdox 'get_compatible_features_for_plugin' fails when invoked before the 'woocommerce_init' hook. + */ + public function test_get_compatible_features_for_plugin_before_woocommerce_init_hook() { + $function = null; + $message = null; + $version = null; + + $this->register_legacy_proxy_function_mocks( + array( + 'did_action' => function( $action_name ) { + return 'woocommerce_init' === $action_name ? false : did_action( $action_name ); + }, + 'wc_doing_it_wrong' => function( $f, $m, $v ) use ( &$function, &$message, &$version ) { + $function = $f; + $message = $m; + $version = $v; + }, + ) + ); + + $this->sut->get_compatible_features_for_plugin( 'the_plugin' ); + + $this->assertEquals( 'FeaturesController::get_compatible_features_for_plugin', $function ); + $this->assertEquals( 'FeaturesController::get_compatible_features_for_plugin should not be called before the woocommerce_init action.', $message ); + $this->assertEquals( '7.0', $version ); + } + + /** + * @testdox 'get_compatible_features_for_plugin' returns empty information for a plugin that has not declared compatibility with any feature. + */ + public function test_get_compatible_features_for_unregistered_plugin() { + $this->simulate_after_woocommerce_init_hook(); + + $result = $this->sut->get_compatible_features_for_plugin( 'the_plugin' ); + + $expected = array( + 'compatible' => array(), + 'incompatible' => array(), + ); + $this->assertEquals( $expected, $result ); + } + + /** + * @testdox 'get_compatible_features_for_plugin' returns proper information for a plugin that has declared compatibility with the passed feature. + */ + public function test_get_compatible_features_for_registered_plugin() { + $this->simulate_inside_before_woocommerce_init_hook(); + + $this->sut->declare_compatibility( 'mature1', 'the_plugin', true ); + $this->sut->declare_compatibility( 'mature2', 'the_plugin', true ); + $this->sut->declare_compatibility( 'experimental1', 'the_plugin', false ); + $this->sut->declare_compatibility( 'experimental2', 'the_plugin', false ); + + $this->reset_legacy_proxy_mocks(); + + $this->simulate_after_woocommerce_init_hook(); + + $result = $this->sut->get_compatible_features_for_plugin( 'the_plugin' ); + + $expected = array( + 'compatible' => array( 'mature1', 'mature2' ), + 'incompatible' => array( 'experimental1', 'experimental2' ), + ); + $this->assertEquals( $expected, $result ); + } + + /** + * @testdox 'get_compatible_plugins_for_feature' fails when invoked before the 'woocommerce_init' hook. + */ + public function test_get_compatible_plugins_for_feature_before_woocommerce_init_hook() { + $function = null; + $message = null; + $version = null; + + $this->register_legacy_proxy_function_mocks( + array( + 'did_action' => function( $action_name ) { + return 'woocommerce_init' === $action_name ? false : did_action( $action_name ); + }, + 'wc_doing_it_wrong' => function( $f, $m, $v ) use ( &$function, &$message, &$version ) { + $function = $f; + $message = $m; + $version = $v; + }, + ) + ); + + $this->sut->get_compatible_plugins_for_feature( 'mature1' ); + + $this->assertEquals( 'FeaturesController::get_compatible_plugins_for_feature', $function ); + $this->assertEquals( 'FeaturesController::get_compatible_plugins_for_feature should not be called before the woocommerce_init action.', $message ); + $this->assertEquals( '7.0', $version ); + } + + /** + * @testdox 'get_compatible_plugins_for_feature' returns empty information for invalid feature ids. + */ + public function test_get_compatible_plugins_for_non_existing_feature() { + $this->simulate_after_woocommerce_init_hook(); + + $result = $this->sut->get_compatible_plugins_for_feature( 'NON_EXISTING' ); + + $expected = array( + 'compatible' => array(), + 'incompatible' => array(), + ); + $this->assertEquals( $expected, $result ); + } + + /** + * @testdox 'get_compatible_plugins_for_feature' returns empty information for features for which no compatibility has been declared. + */ + public function test_get_compatible_plugins_for_existing_feature_without_compatibility_declarations() { + $this->simulate_after_woocommerce_init_hook(); + + $result = $this->sut->get_compatible_plugins_for_feature( 'mature1' ); + + $expected = array( + 'compatible' => array(), + 'incompatible' => array(), + ); + $this->assertEquals( $expected, $result ); + } + + /** + * @testdox 'get_compatible_plugins_for_feature' returns proper information for a feature for which compatibility has been declared. + */ + public function test_get_compatible_plugins_for_feature() { + $this->simulate_inside_before_woocommerce_init_hook(); + + $this->sut->declare_compatibility( 'mature1', 'the_plugin_1', true ); + $this->sut->declare_compatibility( 'mature1', 'the_plugin_2', true ); + $this->sut->declare_compatibility( 'mature1', 'the_plugin_3', false ); + + $this->reset_legacy_proxy_mocks(); + + $this->simulate_after_woocommerce_init_hook(); + + $result = $this->sut->get_compatible_plugins_for_feature( 'mature1' ); + + $expected = array( + 'compatible' => array( 'the_plugin_1', 'the_plugin_2' ), + 'incompatible' => array( 'the_plugin_3' ), + ); + + $this->assertEquals( $expected, $result ); + } + + /** + * @testdox The action defined by FEATURE_ENABLED_CHANGED_ACTION is fired when the enable status of a feature changes. + * + * @testWith [true] + * [false] + * + * @param bool $do_enable Whether to enable or disable the feature. + */ + public function test_feature_enable_changed_hook( $do_enable ) { + $feature_id = null; + $enabled = null; + + add_action( + FeaturesController::FEATURE_ENABLED_CHANGED_ACTION, + function( $f, $e ) use ( &$feature_id, &$enabled ) { + $feature_id = $f; + $enabled = $e; + }, + 10, + 2 + ); + + $this->sut->change_feature_enable( 'mature1', $do_enable ); + + $this->assertEquals( 'mature1', $feature_id ); + $this->assertEquals( $do_enable, $enabled ); + } + + /** + * Simulates that the code is running inside the 'before_woocommerce_init' action. + */ + private function simulate_inside_before_woocommerce_init_hook() { + $this->register_legacy_proxy_function_mocks( + array( + 'doing_action' => function( $action_name ) { + return 'before_woocommerce_init' === $action_name || doing_action( $action_name ); + }, + ) + ); + } + + /** + * Simulates that the code is running after the 'woocommerce_init' action has been fired. + */ + private function simulate_after_woocommerce_init_hook() { + $this->register_legacy_proxy_function_mocks( + array( + 'did_action' => function( $action_name ) { + return 'woocommerce_init' === $action_name || did_action( $action_name ); + }, + ) + ); + } +} diff --git a/plugins/woocommerce/tests/php/src/Utilities/ArrayUtilTest.php b/plugins/woocommerce/tests/php/src/Utilities/ArrayUtilTest.php index 2ba949d6cc7..a7f7e02a661 100644 --- a/plugins/woocommerce/tests/php/src/Utilities/ArrayUtilTest.php +++ b/plugins/woocommerce/tests/php/src/Utilities/ArrayUtilTest.php @@ -275,4 +275,80 @@ class ArrayUtilTest extends \WC_Unit_Test_Case { $this->assertTrue( $result ); $this->assertEquals( array( 1, 2, 3, 4 ), $array ); } + + + /** + * @testdox `ensure_key_is_array` adds an empty array under the given key if they key doesn't exist already in the array. + */ + public function test_ensure_key_is_array_when_key_does_not_exist() { + $array = array( 'foo' => 1 ); + $result = ArrayUtil::ensure_key_is_array( $array, 'bar' ); + $this->assertTrue( $result ); + $this->assertEquals( + array( + 'foo' => 1, + 'bar' => array(), + ), + $array + ); + } + + /** + * @testdox `ensure_key_is_array` does nothing if the key already exists in the array and $throw_if_existing_is_not_array is false. + * + * @testWith [[]] + * [1] + * [true] + * ["Foo"] + * + * @param mixed $value The already existing value. + */ + public function test_ensure_key_is_array_when_key_exist_and_not_throwing( $value ) { + $array = array( + 'foo' => 1, + 'bar' => $value, + ); + $result = ArrayUtil::ensure_key_is_array( $array, 'bar' ); + $this->assertFalse( $result ); + $this->assertEquals( + array( + 'foo' => 1, + 'bar' => $value, + ), + $array + ); + } + + /** + * @testdox `ensure_key_is_array` does nothing if the key already exists in the array, the value is itself an array, and $throw_if_existing_is_not_array is true. + */ + public function test_ensure_key_is_array_when_key_is_array_and_throwing() { + $array = array( + 'foo' => 1, + 'bar' => array(), + ); + $result = ArrayUtil::ensure_key_is_array( $array, 'bar', true ); + $this->assertFalse( $result ); + $this->assertEquals( + array( + 'foo' => 1, + 'bar' => array(), + ), + $array + ); + } + + /** + * @testdox `ensure_key_is_array` throws if the key already exists in the array, the value is not an array, and $throw_if_existing_is_not_array is true. + */ + public function test_ensure_key_is_array_when_key_is_not_array_and_throwing() { + $this->expectException( \Exception::class ); + $this->expectExceptionMessage( "Array key exists but it's not an array, it's a integer" ); + + $array = array( + 'foo' => 1, + 'bar' => 2, + ); + ArrayUtil::ensure_key_is_array( $array, 'bar', true ); + } } diff --git a/plugins/woocommerce/tests/php/src/Utilities/StringUtilTest.php b/plugins/woocommerce/tests/php/src/Utilities/StringUtilTest.php index d52398b0e98..96f0e5f0b2c 100644 --- a/plugins/woocommerce/tests/php/src/Utilities/StringUtilTest.php +++ b/plugins/woocommerce/tests/php/src/Utilities/StringUtilTest.php @@ -50,7 +50,7 @@ class StringUtilTest extends \WC_Unit_Test_Case { } /** - * @return void 'contains' should check whether one string contains another. + * @@testdox 'contains' should check whether one string contains another. */ public function test_contains() { $this->assertFalse( StringUtil::contains( 'foobar', 'fizzbuzz' ) ); @@ -64,4 +64,14 @@ class StringUtilTest extends \WC_Unit_Test_Case { $this->assertTrue( StringUtil::contains( 'foobar', 'BA', false ) ); } + + /** + * @testdox 'plugin_name_from_plugin_file' returns the plugin name in the form 'directory/file.php' from the plugin file. + */ + public function test_plugin_name_from_plugin_file() { + $file_path = '/home/someone/wordpress/wp-content/plugins/foobar/fizzbuzz.php'; + $result = StringUtil::plugin_name_from_plugin_file( $file_path ); + $expected = 'foobar/fizzbuzz.php'; + $this->assertEquals( $expected, $result ); + } }