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 );
+ }
}