PluginProbe ʕ •ᴥ•ʔ
WooCommerce / 10.9.3
WooCommerce v10.9.3
10.9.4 10.9.3 10.9.2 10.9.1 10.9.0 10.9.0-rc.1 10.9.0-beta.2 10.9.0-beta.1 10.8.1 10.8.0 10.8.0-rc.1 10.8.0-beta.2 10.8.0-beta.1 7.8.0-beta.1 7.8.0-beta.2 7.8.0-rc.1 7.8.0-rc.2 7.8.1 7.8.2 7.8.3 7.8.4 7.9.0 7.9.0-beta.1 7.9.0-beta.2 7.9.0-rc.2 7.9.0-rc.3 7.9.1 7.9.2 8.0.0 8.0.0-beta.1 8.0.0-beta.2 8.0.0-rc.1 8.0.0-rc.2 8.0.1 8.0.2 8.0.3 8.0.4 8.0.5 8.1.0 8.1.0-beta.1 8.1.0-rc.1 8.1.0-rc.2 8.1.1 8.1.2 8.1.3 8.1.4 8.2.0 8.2.0-beta.1 8.2.0-rc.1 8.2.0-rc.2 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.3.0 8.3.0-beta.1 8.3.0-rc.1 8.3.0-rc.2 8.3.1 8.3.2 8.3.3 8.3.4 8.4.0 8.4.0-beta.1 8.4.0-rc.1 8.4.1 8.4.2 8.4.3 8.5.0 8.5.0-beta.1 8.5.0-rc.1 8.5.1 8.5.2 8.5.3 8.5.4 8.5.5 8.6.0 8.6.0-beta.1 8.6.0-rc.1 8.6.1 8.6.2 8.6.3 8.6.4 8.7.0 8.7.0-beta.1 8.7.0-beta.2 8.7.0-rc.1 8.7.1 8.7.2 8.7.3 8.8.0 8.8.0-beta.1 8.8.0-rc.1 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.8.6 8.8.7 8.9.0 8.9.0-beta.1 8.9.0-rc.1 8.9.1 8.9.2 8.9.3 8.9.4 8.9.5 9.0.0 9.0.0-beta.1 9.0.0-beta.2 9.0.0-rc.1 9.0.1 9.0.2 9.0.3 9.0.4 9.1.0 9.1.0-beta.1 9.1.0-rc.1 9.1.1 9.1.2 9.1.3 9.1.4 9.1.5 9.1.6 9.2.0 9.2.0-beta.1 9.2.0-rc.1 9.2.1 9.2.2 9.2.3 9.2.4 9.2.5 9.3.0 9.3.0-beta.1 9.3.0-rc.1 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.3.6 9.4.0 9.4.0-beta.1 9.4.0-beta.2 9.4.0-rc.1 9.4.0-rc.2 9.4.0-rc.3 9.4.0-rc.4 9.4.1 9.4.2 9.4.3 9.4.4 9.4.5 9.5.0 9.5.0-beta.1 9.5.0-beta.2 9.5.0-rc.1 9.5.1 9.5.2 9.5.3 9.5.4 9.6.0 9.6.0-beta.1 9.6.0-beta.2 9.6.0-rc.1 9.6.1 9.6.2 9.6.3 9.6.4 9.7.0 9.7.0-beta.1 9.7.0-rc.1 9.7.1 9.7.2 9.7.3 9.8.0 9.8.0-beta.1 9.8.0-rc.1 9.8.1 9.8.2 9.8.3 9.8.4 9.8.5 9.8.6 9.8.7 9.9.0 9.9.0-beta.1 9.9.0-rc.1 9.9.1 9.9.2 9.9.3 9.9.4 9.9.5 9.9.6 9.9.7 3.7.3 7.1.2 3.8.0 7.2.0 3.8.0-beta.1 7.2.0-beta.1 3.8.0-rc.1 7.2.0-beta.2 3.8.0-rc.2 7.2.0-rc.1 3.8.1 7.2.0-rc.2 3.8.2 7.2.1 3.8.3 7.2.2 3.9.0 7.2.3 3.9.0-beta.1 7.2.4 3.9.0-beta.2 7.3.0 3.9.0-rc.1 7.3.0-beta.1 3.9.0-rc.2 7.3.0-beta.2 3.9.0-rc.3 7.3.0-rc.1 3.9.0-rc.4 7.3.0-rc.2 3.9.1 7.3.1 3.9.2 7.4.0 3.9.3 7.4.0-beta.1 3.9.4 7.4.0-beta.2 3.9.5 7.4.0-rc.1 4.0.0 7.4.0-rc.2 4.0.0-beta.1 7.4.1 4.0.0-rc.1 7.4.2 4.0.0-rc.2 7.5.0 4.0.1 7.5.0-beta.1 4.0.2 7.5.0-beta.2 4.0.3 7.5.0-rc.1 4.0.4 7.5.1 4.1.0 7.5.2 4.1.0-beta.1 7.6.0 4.1.0-beta.2 7.6.0-beta.1 4.1.0-rc.1 7.6.0-beta.2 4.1.0-rc.2 7.6.0-rc.1 4.1.1 7.6.0-rc.2 4.1.2 7.6.0-rc.3 4.1.3 7.6.1 4.1.4 7.6.2 4.2.0 7.7.0 4.2.0-RC.1 7.7.0-beta.1 4.2.0-RC.2 7.7.0-beta.2 4.2.0-beta.1 7.7.0-rc.1 4.2.1 7.7.1 4.2.2 7.7.2 4.2.3 7.7.3 4.2.4 7.8.0 4.2.5 4.3.0 4.3.0-beta.1 4.3.0-rc.1 4.3.0-rc.2 4.3.0-rc.3 4.3.1 4.3.2 4.3.3 4.3.4 4.3.5 4.3.6 4.4.0 4.4.0-beta.1 4.4.0-rc.1 4.4.1 4.4.2 4.4.3 4.4.4 4.5.0 4.5.0-beta.1 4.5.0-rc.1 4.5.0-rc.3 4.5.1 4.5.2 4.5.3 4.5.4 4.5.5 4.6.0 4.6.0-beta.1 4.6.0-rc.1 4.6.1 4.6.2 4.6.3 4.6.4 4.6.5 4.7.0 4.7.0-beta.1 4.7.0-beta.2 4.7.0-rc.1 4.7.1 4.7.1-beta.1 4.7.2 4.7.3 4.7.4 4.8.0 4.8.0-beta.1 4.8.0-rc.1 4.8.0-rc.2 4.8.1 4.8.2 4.8.3 4.9.0 4.9.0-beta.1 4.9.0-rc.1 4.9.0-rc.2 4.9.1 4.9.2 4.9.3 4.9.4 4.9.5 5.0.0 5.0.0-beta.1 5.0.0-beta.2 5.0.0-rc.1 5.0.0-rc.2 5.0.0-rc.3 5.0.1 5.0.2 5.0.3 5.1.0 5.1.0-beta.1 5.1.0-rc.1 trunk 5.1.1 10.0.0 5.1.2 10.0.0-rc.1 5.1.3 10.0.0-rc.2 5.2.0 10.0.1 5.2.0-beta.1 10.0.2 5.2.0-rc.1 10.0.3 5.2.0-rc.2 10.0.4 5.2.1 10.0.5 5.2.2 10.0.6 5.2.3 10.1.0 5.2.4 10.1.0-rc.1 5.2.5 10.1.0-rc.2 5.3.0 10.1.0-rc.3 5.3.0-beta.1 10.1.0-rc.4 5.3.0-rc.1 10.1.1 5.3.0-rc.2 10.1.2 5.3.1 10.1.3 5.3.2 10.1.4 5.3.3 10.2.0 5.4.0 10.2.0-beta.1 5.4.0-beta.1 10.2.0-beta.2 5.4.0-rc.1 10.2.0-rc.1 5.4.1 10.2.1 5.4.2 10.2.2 5.4.3 10.2.3 5.4.4 10.2.4 5.4.5 10.3.0 5.5.0 10.3.0-beta.1 5.5.0-beta.1 10.3.0-beta.2 5.5.0-rc.1 10.3.0-rc.1 5.5.0-rc.2 10.3.0-rc.2 5.5.1 10.3.1 5.5.2 10.3.2 5.5.3 10.3.3 5.5.4 10.3.4 5.5.5 10.3.5 5.6.0 10.3.6 5.6.0-beta.1 10.3.7 5.6.0-rc.1 10.3.8 5.6.0-rc.2 10.4.0 5.6.1 10.4.0-beta.1 5.6.2 10.4.0-beta.2 5.6.3 10.4.0-rc.1 5.7.0 10.4.1 5.7.0-beta.1 10.4.2 5.7.0-rc.1 10.4.3 5.7.1 10.4.4 5.7.2 10.5.0 5.7.3 10.5.0-beta.1 5.8.0 10.5.0-beta.2 5.8.0-beta.1 10.5.0-rc.1 5.8.0-beta.2 10.5.0-rc.2 5.8.0-rc.1 10.5.0-rc.3 5.8.1 10.5.1 5.8.2 10.5.2 5.9.0 10.5.3 5.9.0-beta.1 10.6.0 5.9.0-rc.1 10.6.0-beta.1 5.9.0-rc.2 10.6.0-beta.2 5.9.1 10.6.0-rc.1 5.9.2 10.6.1 6.0.0 10.6.2 6.0.0-beta.1 10.7.0 6.0.0-rc.1 10.7.0-beta.1 6.0.1 10.7.0-beta.2 6.0.2 10.7.0-rc.1 6.1.0 3.0.0 6.1.0-beta.1 3.0.1 6.1.0-rc.1 3.0.2 6.1.0-rc.2 3.0.3 6.1.1 3.0.4 6.1.2 3.0.5 6.1.3 3.0.6 6.2.0 3.0.7 6.2.0-beta.1 3.0.8 6.2.0-rc.1 3.0.9 6.2.0-rc.2 3.1.0 6.2.1 3.1.1 6.2.2 3.1.2 6.2.3 3.2.0 6.3.0 3.2.1 6.3.0-beta.1 3.2.2 6.3.0-rc.1 3.2.3 6.3.0-rc.2 3.2.4 6.3.1 3.2.5 6.3.2 3.2.6 6.4.0 3.3.0 6.4.0-beta.1 3.3.1 6.4.0-rc.1 3.3.2 6.4.1 3.3.2-rc.1 6.4.2 3.3.3 6.5.0 3.3.4 6.5.0-beta.1 3.3.5 6.5.0-rc.1 3.3.6 6.5.0-rc.2 3.4.0 6.5.1 3.4.0-beta.1 6.5.2 3.4.0-rc.2 6.6.0 3.4.1 6.6.0-beta.1 3.4.2 6.6.0-rc.1 3.4.3 6.6.0-rc.2 3.4.4 6.6.1 3.4.5 6.6.2 3.4.6 6.7.0 3.4.7 6.7.0-beta.1 3.4.8 6.7.0-beta.2 3.5.0 6.7.0-rc.1 3.5.0-beta.1 6.7.1 3.5.0-rc.1 6.8.0 3.5.0-rc.2 6.8.0-beta.1 3.5.1 6.8.0-beta.2 3.5.10 6.8.0-rc.1 3.5.2 6.8.1 3.5.3 6.8.2 3.5.4 6.8.3 3.5.5 6.9.0 3.5.6 6.9.0-beta.1 3.5.7 6.9.0-beta.2 3.5.8 6.9.0-rc.1 3.5.9 6.9.1 3.6.0 6.9.2 3.6.0-beta.1 6.9.3 3.6.0-rc.1 6.9.4 3.6.0-rc.2 6.9.5 3.6.0-rc.3 7.0.0 3.6.1 7.0.0-beta.1 3.6.2 7.0.0-beta.2 3.6.3 7.0.0-beta.3 3.6.4 7.0.0-rc.1 3.6.5 7.0.0-rc.2 3.6.6 7.0.1 3.6.7 7.0.2 3.7.0 7.1.0 3.7.0-beta.1 7.1.0-beta.1 3.7.0-rc.1 7.1.0-beta.2 3.7.0-rc.2 7.1.0-rc.1 3.7.1 7.1.0-rc.2 3.7.2 7.1.1
woocommerce / src / Internal / Features / FeaturesController.php
woocommerce / src / Internal / Features Last commit date
OrderDetailRedesign 1 month ago ProductBlockEditor 1 month ago FeaturesController.php 6 days ago
FeaturesController.php
2095 lines
1 <?php
2 /**
3 * FeaturesController class file
4 */
5
6 declare( strict_types=1 );
7
8 namespace Automattic\WooCommerce\Internal\Features;
9
10 use Automattic\WooCommerce\Internal\Admin\EmailPreview\EmailPreview;
11 use WC_Tracks;
12 use WC_Site_Tracking;
13 use Automattic\Jetpack\Constants;
14 use Automattic\WooCommerce\Internal\Admin\Analytics;
15 use Automattic\WooCommerce\Internal\Caches\ProductCacheController;
16 use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
17 use Automattic\WooCommerce\Internal\CostOfGoodsSold\CostOfGoodsSoldController;
18 use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
19 use Automattic\WooCommerce\Proxies\LegacyProxy;
20 use Automattic\WooCommerce\Utilities\ArrayUtil;
21 use Automattic\WooCommerce\Utilities\PluginUtil;
22 use Automattic\WooCommerce\Enums\FeaturePluginCompatibility;
23
24 defined( 'ABSPATH' ) || exit;
25
26 /**
27 * Class to define the WooCommerce features that can be enabled and disabled by admin users,
28 * provides also a mechanism for WooCommerce plugins to declare that they are compatible
29 * (or incompatible) with a given feature.
30 *
31 * Note: the 'woocommerce_register_feature_definitions' hook allows registering new features
32 * externally. This hook is deprecated, features should be registered from within get_feature_definitions.
33 * However, in case you use it for testing purposes, keep in mind that the hook is fired from inside 'init';
34 * therefore, features that need to be queried, enabled, or disabled before 'init' (e.g. during WP CLI initialization)
35 * can't be registered using the hook.
36 */
37 class FeaturesController {
38
39 public const FEATURE_ENABLED_CHANGED_ACTION = 'woocommerce_feature_enabled_changed';
40
41 public const PLUGINS_COMPATIBLE_BY_DEFAULT_OPTION = 'woocommerce_plugins_are_compatible_with_features_by_default';
42
43 /**
44 * The existing feature definitions.
45 *
46 * @var array[]
47 */
48 private $features = array();
49
50 /**
51 * The registered compatibility info for WooCommerce plugins, with plugin names as keys.
52 *
53 * @var array
54 */
55 private $compatibility_info_by_plugin = array();
56
57 /**
58 * The registered compatibility info for WooCommerce plugins, with feature ids as keys.
59 *
60 * @var array
61 */
62 private $compatibility_info_by_feature = array();
63
64 /**
65 * Pending compatibility declarations. Format is [feature_id, plugin_file, positive_compatibility].
66 *
67 * @var array
68 */
69 private $pending_declarations = array();
70
71 /**
72 * The LegacyProxy instance to use.
73 *
74 * @var LegacyProxy
75 */
76 private $proxy;
77
78 /**
79 * The PluginUtil instance to use.
80 *
81 * @var PluginUtil
82 */
83 private $plugin_util;
84
85 /**
86 * Flag indicating that features will be enableable from the settings page
87 * even when they are incompatible with active plugins.
88 *
89 * @var bool
90 */
91 private $force_allow_enabling_features = false;
92
93 /**
94 * Flag indicating that plugins will be activable from the plugins page
95 * even when they are incompatible with enabled features.
96 *
97 * @var bool
98 */
99 private $force_allow_enabling_plugins = false;
100
101 /**
102 * List of plugins excluded from feature compatibility warnings in UI.
103 *
104 * @var string[]
105 */
106 private $plugins_excluded_from_compatibility_ui;
107
108 /**
109 * Flag indicating if additional features have been registered already
110 * via woocommerce_register_feature_definitions action.
111 *
112 * @var bool
113 */
114 private bool $registered_additional_features_via_action = false;
115
116 /**
117 * Flag indicating if additional features have been registered already
118 * via calls to other classes.
119 *
120 * @var bool
121 */
122 private bool $registered_additional_features_via_class_calls = false;
123
124 /**
125 * Flag indicating if we are currently delaying plugin normalization.
126 *
127 * @var bool
128 */
129 private bool $lazy = true;
130
131 /**
132 * Creates a new instance of the class.
133 */
134 public function __construct() {
135 // In principle, register_additional_features is triggered manually from within class-woocommerce
136 // right before before_woocommerce_init is fired (this is needed for the features to be visible
137 // to plugins executing declare_compatibility).
138 // However we add additional checks/hookings here to support unit tests and possible overlooked/future
139 // DI container/class instantiation nuances.
140 if ( ! $this->registered_additional_features_via_action ) {
141 if ( did_action( 'before_woocommerce_init' ) ) {
142 // Needed for unit tests, where 'before_woocommerce_init' will have been fired already at this point.
143 $this->register_additional_features();
144 } else {
145 // This needs to have a higher $priority than the 'before_woocommerce_init' hooked by plugins that declare compatibility.
146 add_filter( 'before_woocommerce_init', array( $this, 'register_additional_features' ), -9999, 0 );
147 }
148 }
149
150 if ( did_action( 'init' ) ) {
151 // Needed for unit tests, where 'init' will have been fired already at this point.
152 $this->start_listening_for_option_changes();
153 } else {
154 add_filter( 'init', array( $this, 'start_listening_for_option_changes' ), 10, 0 );
155 }
156
157 add_filter( 'woocommerce_get_sections_advanced', array( $this, 'add_features_section' ), 10, 1 );
158 add_filter( 'woocommerce_get_settings_advanced', array( $this, 'add_feature_settings' ), 10, 2 );
159 add_filter( 'deactivated_plugin', array( $this, 'handle_plugin_deactivation' ), 10, 1 );
160 add_filter( 'all_plugins', array( $this, 'filter_plugins_list' ), 10, 1 );
161 add_action( 'admin_notices', array( $this, 'display_notices_in_plugins_page' ), 10, 0 );
162 add_action( 'load-plugins.php', array( $this, 'maybe_invalidate_cached_plugin_data' ) );
163 add_action( 'after_plugin_row', array( $this, 'handle_plugin_list_rows' ), 10, 2 );
164 add_action( 'current_screen', array( $this, 'enqueue_script_to_fix_plugin_list_html' ), 10, 1 );
165 add_filter( 'views_plugins', array( $this, 'handle_plugins_page_views_list' ), 10, 1 );
166 add_filter( 'woocommerce_admin_shared_settings', array( $this, 'set_change_feature_enable_nonce' ), 20, 1 );
167 add_action( 'admin_init', array( $this, 'change_feature_enable_from_query_params' ), 20, 0 );
168 add_action( self::FEATURE_ENABLED_CHANGED_ACTION, array( $this, 'display_email_improvements_feedback_notice' ), 10, 2 );
169 }
170
171 /**
172 * Register a feature.
173 *
174 * This used to be called during the `woocommerce_register_feature_definitions` action hook,
175 * now it's called directly from get_feature_definitions as needed.
176 *
177 * @param string $slug The ID slug of the feature.
178 * @param string $name The name of the feature that will appear on the Features screen and elsewhere.
179 * @param array $args {
180 * Properties that make up the feature definition. Each of these properties can also be set as a
181 * callback function, as long as that function returns the specified type.
182 *
183 * @type string $default_plugin_compatibility The default plugin compatibility for the feature: either 'compatible' or 'incompatible'. Required.
184 * @type array[] $additional_settings An array of definitions for additional settings controls related to
185 * the feature that will display on the Features screen. See the Settings API
186 * for the schema of these props.
187 * @type string $description A brief description of the feature, used as an input label if the feature
188 * setting is a checkbox.
189 * @type bool $disabled True to disable the setting field for this feature on the Features screen,
190 * so it can't be changed.
191 * @type bool $disable_ui Set to true to hide the setting field for this feature on the
192 * Features screen. Defaults to false.
193 * @type bool $enabled_by_default Set to true to have this feature by opt-out instead of opt-in.
194 * Defaults to false.
195 * @type bool $is_experimental Set to true to display this feature under the "Experimental" heading on
196 * the Features screen. Features set to experimental are also omitted from
197 * the features list in some cases. Defaults to true.
198 * @type bool $skip_compatibility_checks Set to true if the feature should not produce warnings about incompatible plugins.
199 * Defaults to false.
200 * @type string $learn_more_url The URL to the learn more page for the feature.
201 * @type string $option_key The key name for the option that enables/disables the feature.
202 * @type int $order The order that the feature will appear in the list on the Features screen.
203 * Higher number = higher in the list. Defaults to 10.
204 * @type array $setting The properties used by the Settings API to render the setting control on
205 * the Features screen. See the Settings API for the schema of these props.
206 * @type string $deprecated_since The WooCommerce version since which this feature is deprecated.
207 * When set, feature_is_enabled() will force feature value to the deprecated_value
208 * instead of reading from the database.
209 * @type bool $deprecated_value The value to return for deprecated features when feature_is_enabled()
210 * is called. Defaults to false.
211 * }
212 *
213 * @return void
214 */
215 public function add_feature_definition( $slug, $name, array $args = array() ) {
216 $defaults = array(
217 'disable_ui' => false,
218 'enabled_by_default' => false,
219 'is_experimental' => true,
220 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
221 'skip_compatibility_checks' => false,
222 'name' => $name,
223 'order' => 10,
224 'learn_more_url' => '',
225 );
226
227 if ( empty( $args['default_plugin_compatibility'] ) ) {
228 wc_doing_it_wrong(
229 __FUNCTION__,
230 sprintf(
231 'Assuming positive compatibility by default will be deprecated in the future. Please set \'default_plugin_compatibility\' for feature "%s".',
232 esc_html( $slug )
233 ),
234 '10.3.0'
235 );
236 }
237
238 $args = wp_parse_args( $args, $defaults );
239
240 // Sanitize 'default_plugin_compatibility'.
241 if ( ! in_array( $args['default_plugin_compatibility'], FeaturePluginCompatibility::VALID_REGISTRATION_VALUES, true ) ) {
242 $args['default_plugin_compatibility'] = wc_string_to_bool( $args['default_plugin_compatibility'] ) ? FeaturePluginCompatibility::COMPATIBLE : FeaturePluginCompatibility::INCOMPATIBLE;
243 }
244
245 // Support 'is_legacy' flag for backwards compatibility.
246 if ( ! empty( $args['is_legacy'] ) ) {
247 $args['skip_compatibility_checks'] = true;
248 }
249
250 $this->features[ $slug ] = $args;
251 }
252
253 /**
254 * Generate and cache the feature definitions.
255 *
256 * @return array[]
257 */
258 private function get_feature_definitions() {
259 if ( empty( $this->features ) ) {
260 $this->init_feature_definitions();
261 }
262
263 if ( ! $this->registered_additional_features_via_class_calls ) {
264 // This needs to be set to true *before* additional feature definition calls are made,
265 // to prevent infinite loops in case one of these calls ends up calling here again.
266 $this->registered_additional_features_via_class_calls = true;
267
268 // Additional feature definitions.
269 // These used to be tied to the now deprecated woocommerce_register_feature_definitions action,
270 // and aren't processed in init_feature_definitions to avoid circular calls in the dependency injection container.
271 $container = wc_get_container();
272 $container->get( CustomOrdersTableController::class )->add_feature_definition( $this );
273 $container->get( CostOfGoodsSoldController::class )->add_feature_definition( $this );
274
275 $this->init_compatibility_info_by_feature();
276 }
277
278 return $this->features;
279 }
280
281 /**
282 * Initialize the hardcoded feature definitions array.
283 * This doesn't include:
284 * - Features that get initialized via the (deprecated) woocommerce_register_feature_definitions.
285 * - Features whose definition comes from another class. These are initialized directly in get_feature_definitions
286 * to avoid circular calls in the dependency injection container.
287 */
288 private function init_feature_definitions(): void {
289 $alpha_feature_testing_is_enabled = Constants::is_true( 'WOOCOMMERCE_ENABLE_ALPHA_FEATURE_TESTING' );
290 $tracking_enabled = WC_Site_Tracking::is_tracking_enabled();
291
292 $legacy_features = array(
293 'analytics' => array(
294 'name' => __( 'Analytics', 'woocommerce' ),
295 'description' => __( 'Enable WooCommerce Analytics', 'woocommerce' ),
296 'option_key' => Analytics::TOGGLE_OPTION_NAME,
297 'is_experimental' => false,
298 'enabled_by_default' => true,
299 'disable_ui' => false,
300 'skip_compatibility_checks' => true,
301 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
302 ),
303 'product_block_editor' => array(
304 'name' => __( 'New product editor', 'woocommerce' ),
305 'description' => __( 'Try the new product editor (Beta)', 'woocommerce' ),
306 'is_experimental' => true,
307 'disable_ui' => false,
308 'skip_compatibility_checks' => true,
309 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
310 ),
311 'cart_checkout_blocks' => array(
312 'name' => __( 'Cart & Checkout Blocks', 'woocommerce' ),
313 'description' => __( 'Optimize for faster checkout', 'woocommerce' ),
314 'is_experimental' => false,
315 'disable_ui' => true,
316 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
317 ),
318 'rate_limit_checkout' => array(
319 'name' => __( 'Rate limit Checkout', 'woocommerce' ),
320 'description' => sprintf(
321 // translators: %s is the URL to the rate limiting documentation.
322 __( 'Enables rate limiting for Checkout place order and Store API /checkout endpoint. To further control this, refer to <a href="%s" target="_blank">rate limiting documentation</a>.', 'woocommerce' ),
323 'https://developer.woocommerce.com/docs/apis/store-api/rate-limiting/'
324 ),
325 'is_experimental' => false,
326 'disable_ui' => false,
327 'enabled_by_default' => false,
328 'skip_compatibility_checks' => true,
329 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
330 ),
331 'marketplace' => array(
332 'name' => __( 'Marketplace', 'woocommerce' ),
333 'description' => __(
334 'New, faster way to find extensions and themes for your WooCommerce store',
335 'woocommerce'
336 ),
337 'is_experimental' => false,
338 'enabled_by_default' => true,
339 'disable_ui' => true,
340 'skip_compatibility_checks' => true,
341 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
342 'deprecated_since' => '10.5.0',
343 'deprecated_value' => true,
344 ),
345 // Marked as a legacy feature to avoid compatibility checks, which aren't really relevant to this feature.
346 // https://github.com/woocommerce/woocommerce/pull/39701#discussion_r1376976959.
347 'order_attribution' => array(
348 'name' => __( 'Order Attribution', 'woocommerce' ),
349 'description' => __(
350 'Enable this feature to track and credit channels and campaigns that contribute to orders on your site',
351 'woocommerce'
352 ),
353 'enabled_by_default' => true,
354 'disable_ui' => false,
355 'skip_compatibility_checks' => true,
356 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
357 'is_experimental' => false,
358 ),
359 'site_visibility_badge' => array(
360 'name' => __( 'Site visibility badge', 'woocommerce' ),
361 'description' => __(
362 'Enable the site visibility badge in the WordPress admin bar',
363 'woocommerce'
364 ),
365 'enabled_by_default' => true,
366 'disable_ui' => false,
367 'skip_compatibility_checks' => true,
368 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
369 'is_experimental' => false,
370 'disabled' => false,
371 ),
372 'hpos_fts_indexes' => array(
373 'name' => __( 'HPOS Full text search indexes', 'woocommerce' ),
374 'description' => __(
375 'Create and use full text search indexes for orders. This feature only works with high-performance order storage.',
376 'woocommerce'
377 ),
378 'is_experimental' => true,
379 'enabled_by_default' => false,
380 'skip_compatibility_checks' => true,
381 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
382 'option_key' => CustomOrdersTableController::HPOS_FTS_INDEX_OPTION,
383 ),
384 'hpos_datastore_caching' => array(
385 'name' => __( 'HPOS Data Caching', 'woocommerce' ),
386 'description' => __(
387 'Enable order data caching in the datastore. This feature only works with high-performance order storage and is recommended for stores using object caching.',
388 'woocommerce'
389 ),
390 'is_experimental' => false,
391 'enabled_by_default' => false,
392 'skip_compatibility_checks' => true,
393 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
394 'disable_ui' => false,
395 'option_key' => CustomOrdersTableController::HPOS_DATASTORE_CACHING_ENABLED_OPTION,
396 ),
397 'remote_logging' => array(
398 'name' => __( 'Remote Logging', 'woocommerce' ),
399 'description' => sprintf(
400 /* translators: %1$s: opening link tag, %2$s: closing link tag */
401 __( 'Allow WooCommerce to send error logs and non-sensitive diagnostic data to help improve WooCommerce. This feature requires %1$susage tracking%2$s to be enabled.', 'woocommerce' ),
402 '<a href="' . admin_url( 'admin.php?page=wc-settings&tab=advanced&section=woocommerce_com' ) . '">',
403 '</a>'
404 ),
405 'enabled_by_default' => true,
406 'disable_ui' => false,
407
408 /*
409 * This is not truly a legacy feature (it is not a feature that pre-dates the FeaturesController),
410 * but we wish to handle compatibility checking in a similar fashion to legacy features. The
411 * rational for setting legacy to true is therefore similar to that of the 'order_attribution'
412 * feature.
413 *
414 * @see https://github.com/woocommerce/woocommerce/pull/39701#discussion_r1376976959
415 */
416 'skip_compatibility_checks' => true,
417 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
418 'is_experimental' => false,
419 'setting' => array(
420 'disabled' => function () use ( $tracking_enabled ) {
421 return ! $tracking_enabled;
422 },
423 'desc_tip' => function () use ( $tracking_enabled ) {
424 if ( ! $tracking_enabled ) {
425 return __( '⚠ Usage tracking must be enabled to use remote logging.', 'woocommerce' );
426 }
427
428 return '';
429 },
430 ),
431 ),
432 'deferred_transactional_emails' => array(
433 'name' => __( 'Deferred emails', 'woocommerce' ),
434 'description' => __(
435 'Send transactional emails asynchronously via Action Scheduler instead of during the current request.',
436 'woocommerce'
437 ),
438 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
439 'enabled_by_default' => false,
440 'is_experimental' => false,
441 ),
442 'customer_review_request' => array(
443 'name' => __( 'Customer review request (beta)', 'woocommerce' ),
444 'description' => __(
445 'Send customers a transactional email after order completion inviting them to review the products they bought, and host the per-order Review Order landing page.',
446 'woocommerce'
447 ),
448 // Skip compatibility checks like the other opt-in transactional-email features.
449 'skip_compatibility_checks' => true,
450 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
451 'enabled_by_default' => false,
452 'is_experimental' => false,
453 ),
454 'email_improvements' => array(
455 'name' => __( 'Email improvements', 'woocommerce' ),
456 'description' => __(
457 'Enable modern email design for transactional emails',
458 'woocommerce'
459 ),
460
461 /*
462 * This is not truly a legacy feature (it is not a feature that pre-dates the FeaturesController),
463 * but as this feature doesn't affect all extensions, and the rollout is fairly short,
464 * we'll skip the compatibility check by marking this as legacy. This is a workaround until
465 * we can implement a more sophisticated compatibility checking system.
466 *
467 * @see https://github.com/woocommerce/woocommerce/issues/39147
468 * @see https://github.com/woocommerce/woocommerce/issues/55540
469 */
470 'skip_compatibility_checks' => true,
471 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
472 'is_experimental' => false,
473 ),
474 'blueprint' => array(
475 'name' => __( 'Blueprint (beta)', 'woocommerce' ),
476 'description' => __(
477 'Enable blueprint to import and export settings in bulk',
478 'woocommerce'
479 ),
480 'enabled_by_default' => true,
481 'disable_ui' => false,
482
483 /*
484 * This is not truly a legacy feature (it is not a feature that pre-dates the FeaturesController),
485 * but we wish to handle compatibility checking in a similar fashion to legacy features. The
486 * rational for setting legacy to true is therefore similar to that of the 'order_attribution'
487 * feature.
488 *
489 * @see https://github.com/woocommerce/woocommerce/pull/39701#discussion_r1376976959
490 */
491 'skip_compatibility_checks' => true,
492 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
493 'is_experimental' => false,
494 ),
495 'block_email_editor' => array(
496 'name' => __( 'Block Email Editor (alpha)', 'woocommerce' ),
497 'description' => __(
498 'Enable the block-based email editor for transactional emails.',
499 'woocommerce'
500 ),
501 'learn_more_url' => 'https://github.com/woocommerce/woocommerce/discussions/52897#discussioncomment-11630256',
502
503 /*
504 * This is not truly a legacy feature (it is not a feature that pre-dates the FeaturesController),
505 * but we wish to handle compatibility checking in a similar fashion to legacy features. The
506 * rational for setting legacy to true is therefore similar to that of the 'order_attribution'
507 * feature.
508 *
509 * @see https://github.com/woocommerce/woocommerce/pull/39701#discussion_r1376976959
510 */
511 'skip_compatibility_checks' => true,
512 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
513 'enabled_by_default' => false,
514 ),
515 \Automattic\WooCommerce\Internal\VariationGallery\Package::FEATURE_ID => array(
516 'name' => __( 'Variation gallery', 'woocommerce' ),
517 'description' => __(
518 'Add multiple images per product variation. Once enabled, the Additional Variation Images extension will be deactivated and its data migrated.',
519 'woocommerce'
520 ),
521 'option_key' => \Automattic\WooCommerce\Internal\VariationGallery\Package::ENABLE_OPTION_NAME,
522 'is_experimental' => true,
523 'enabled_by_default' => false,
524 'skip_compatibility_checks' => true,
525 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
526 ),
527 'wc-visual-attribute' => array(
528 'name' => __( 'Color swatches for attributes', 'woocommerce' ),
529 'description' => __(
530 'Add color swatches to product attribute values.',
531 'woocommerce'
532 ),
533 'option_key' => 'woocommerce_feature_wc_visual_attribute_enabled',
534 'is_experimental' => true,
535 'enabled_by_default' => false,
536 'disable_ui' => false,
537 'skip_compatibility_checks' => true,
538 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
539 ),
540 'point_of_sale' => array(
541 'name' => __( 'Point of Sale', 'woocommerce' ),
542 'description' => __(
543 'Enable Point of Sale functionality in the WooCommerce mobile apps.',
544 'woocommerce'
545 ),
546 'enabled_by_default' => true,
547 'disable_ui' => false,
548
549 /*
550 * This is not truly a legacy feature (it is not a feature that pre-dates the FeaturesController),
551 * but we wish to handle compatibility checking in a similar fashion to legacy features. The
552 * rational for setting legacy to true is therefore similar to that of the 'order_attribution'
553 * feature.
554 *
555 * @see https://github.com/woocommerce/woocommerce/pull/39701#discussion_r1376976959
556 */
557 'skip_compatibility_checks' => true,
558 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
559 'is_experimental' => true,
560 ),
561 'fulfillments' => array(
562 'name' => __( 'Order Fulfillments', 'woocommerce' ),
563 'description' => __(
564 'Enable the Order Fulfillments feature to manage order fulfillment and shipping.',
565 'woocommerce'
566 ),
567 'enabled_by_default' => false,
568 'disable_ui' => true,
569 'is_experimental' => false,
570 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
571 ),
572 'mcp_integration' => array(
573 'name' => __( 'WooCommerce MCP', 'woocommerce' ),
574 'description' => $this->get_mcp_integration_description(),
575 'enabled_by_default' => false,
576 'disable_ui' => false,
577 'is_experimental' => true,
578 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
579 'is_legacy' => false,
580 ),
581 'destroy-empty-sessions' => array(
582 'name' => __( 'Clear Customer Sessions When Empty', 'woocommerce' ),
583 'description' => __(
584 '[Performance] Removes session cookies for non-logged in customers when session data is empty, improving page caching performance. May cause compatibility issues with extensions that depend on the session cookie without using session data.',
585 'woocommerce'
586 ),
587 'enabled_by_default' => false,
588 'is_experimental' => true,
589 'disable_ui' => false,
590 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
591 ),
592 'agentic_checkout' => array(
593 'name' => __( 'Agentic Checkout API', 'woocommerce' ),
594 'description' => __(
595 'Enable the Agentic Checkout API for AI-powered checkout experiences (e.g., ChatGPT). This adds REST API endpoints that allow AI agents to create and manage checkout sessions.',
596 'woocommerce'
597 ),
598 'enabled_by_default' => false,
599 'is_experimental' => true,
600 'disable_ui' => true,
601 'skip_compatibility_checks' => true,
602 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
603 ),
604 'dual_code_graphql_api' => array(
605 'name' => __( 'Dual Code & GraphQL API', 'woocommerce' ),
606 'description' => __(
607 'Experimental code-first API for WooCommerce with automatic GraphQL endpoint generation. Requires PHP 8.1 or later.',
608 'woocommerce'
609 ),
610 'enabled_by_default' => false,
611 'is_experimental' => true,
612 'disable_ui' => true,
613 'skip_compatibility_checks' => true,
614 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
615 ),
616 PushNotifications::FEATURE_NAME => array(
617 'name' => __( 'Push Notifications', 'woocommerce' ),
618 'description' => __(
619 'Enable push notifications for the WooCommerce mobile apps to receive order notifications and store updates.',
620 'woocommerce'
621 ),
622 'is_experimental' => false,
623 'enabled_by_default' => true,
624 'disable_ui' => true,
625 'skip_compatibility_checks' => true,
626 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
627 'deprecated_since' => '10.9.2',
628 'deprecated_value' => true,
629 ),
630 'rest_api_caching' => array(
631 'name' => __( 'REST API Caching', 'woocommerce' ),
632 'description' => sprintf(
633 /* translators: %1$s and %2$s are opening and closing <a> tags */
634 __( 'Enable backend caching and cache control headers for REST API responses via the <code>RestApiCache</code> trait. ⚙️ %1$sConfiguration%2$s', 'woocommerce' ),
635 '<a href="' . admin_url( 'admin.php?page=wc-settings&tab=advanced&section=rest_api_caching' ) . '">',
636 '</a>'
637 ),
638 'enabled_by_default' => false,
639 'is_experimental' => true,
640 'disable_ui' => false,
641 'skip_compatibility_checks' => true,
642 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
643 ),
644 'cart_save_for_later' => array(
645 'name' => __( 'Save for Later in Cart', 'woocommerce' ),
646 'description' => __(
647 'Let shoppers save cart items to a list to purchase later.',
648 'woocommerce'
649 ),
650 'is_experimental' => true,
651 'enabled_by_default' => false,
652 // Custom option_key as we expect this setting to move out of features to
653 // a cart/checkout settings section.
654 'option_key' => 'woocommerce_cart_save_for_later_enabled',
655 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
656 ),
657 'product_wishlist' => array(
658 'name' => __( 'Wishlists', 'woocommerce' ),
659 'description' => __(
660 'Let shoppers save products to a wishlist from product pages. Requires the Add to Cart + Options block on the single-product template.',
661 'woocommerce'
662 ),
663 'is_experimental' => true,
664 'enabled_by_default' => false,
665 'option_key' => 'woocommerce_product_wishlist_enabled',
666 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
667 ),
668 ProductCacheController::FEATURE_NAME => array(
669 'name' => __( 'Cache Product Objects', 'woocommerce' ),
670 'description' => __(
671 '[Performance] Speeds up your store by caching product objects during each request, preventing duplicate product loads. Can improve page load times on product-heavy pages.',
672 'woocommerce'
673 ),
674 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
675 'enabled_by_default' => false,
676 'is_experimental' => true,
677 'disable_ui' => false,
678 ),
679 );
680
681 if ( ! $tracking_enabled ) {
682 // Uncheck the remote logging feature when usage tracking is disabled.
683 $legacy_features['remote_logging']['setting']['value'] = 'no';
684 }
685
686 foreach ( $legacy_features as $slug => $definition ) {
687 $this->add_feature_definition( $slug, $definition['name'], $definition );
688 }
689
690 $this->init_compatibility_info_by_feature();
691 }
692
693 /**
694 * Initialize the compatibility_info_by_feature property after all the features have been added.
695 */
696 private function init_compatibility_info_by_feature() {
697 foreach ( array_keys( $this->features ) as $feature_id ) {
698 if ( ! isset( $this->compatibility_info_by_feature[ $feature_id ] ) ) {
699 $this->compatibility_info_by_feature[ $feature_id ] = array(
700 FeaturePluginCompatibility::COMPATIBLE => array(),
701 FeaturePluginCompatibility::INCOMPATIBLE => array(),
702 );
703 }
704 }
705 }
706
707 /**
708 * Generate the description for the MCP integration feature.
709 *
710 * @return string The feature description with conditional permalink warning and documentation link.
711 */
712 private function get_mcp_integration_description() {
713 $base_description = __( 'Enable WooCommerce MCP (Model Context Protocol) for AI-powered store operations. AI-generated results and actions can be unpredictable - please review before executing in your store.', 'woocommerce' );
714
715 // Check permalink structure requirement.
716 $permalink_structure = get_option( 'permalink_structure' );
717 if ( empty( $permalink_structure ) ) {
718 $permalinks_url = admin_url( 'options-permalink.php' );
719 $permalink_warning = sprintf(
720 '<br><br><strong>%s:</strong> %s <a href="%s">%s</a>',
721 __( 'Configuration Required', 'woocommerce' ),
722 __( 'WordPress permalinks must be set to anything other than "Plain" for MCP to work.', 'woocommerce' ),
723 $permalinks_url,
724 __( 'Configure Permalinks', 'woocommerce' )
725 );
726 // Add documentation link to permalink warning.
727 $documentation_link = sprintf(
728 ' <a href="%s" target="_blank">%s</a>',
729 'https://github.com/woocommerce/woocommerce/blob/trunk/docs/features/mcp/README.md',
730 __( 'Learn more', 'woocommerce' )
731 );
732 return $base_description . $permalink_warning . $documentation_link;
733 }
734
735 // Add documentation link.
736 $documentation_link = sprintf(
737 ' <a href="%s" target="_blank">%s</a>',
738 'https://github.com/woocommerce/woocommerce/blob/trunk/docs/features/mcp/README.md',
739 __( 'Learn more', 'woocommerce' )
740 );
741
742 return $base_description . $documentation_link;
743 }
744
745 /**
746 * Function to trigger the (now deprecated) 'woocommerce_register_feature_definitions' hook.
747 *
748 * This function must execute immediately before the 'before_woocommerce_init'
749 * action is fired, so that feature compatibility declarations happening
750 * in that action find all the features properly declared already.
751 *
752 * @internal
753 */
754 public function register_additional_features() {
755 if ( $this->registered_additional_features_via_action ) {
756 return;
757 }
758
759 if ( empty( $this->features ) ) {
760 $this->init_feature_definitions();
761 }
762
763 /**
764 * The action for registering features.
765 *
766 * @since 8.3.0
767 *
768 * @param FeaturesController $features_controller The instance of FeaturesController.
769 *
770 * @deprecated 9.9.0 Features should be defined directly in get_feature_definitions.
771 */
772 do_action( 'woocommerce_register_feature_definitions', $this );
773
774 $this->init_compatibility_info_by_feature();
775
776 $this->registered_additional_features_via_action = true;
777 }
778
779 /**
780 * Initialize the class instance.
781 *
782 * @internal
783 *
784 * @param LegacyProxy $proxy The instance of LegacyProxy to use.
785 * @param PluginUtil $plugin_util The instance of PluginUtil to use.
786 */
787 final public function init( LegacyProxy $proxy, PluginUtil $plugin_util ) {
788 $this->proxy = $proxy;
789 $this->plugin_util = $plugin_util;
790
791 $this->plugins_excluded_from_compatibility_ui = $plugin_util->get_plugins_excluded_from_compatibility_ui();
792 }
793
794 /**
795 * Get all the existing WooCommerce features.
796 *
797 * Returns an associative array where keys are unique feature ids
798 * and values are arrays with these keys:
799 *
800 * - name (string)
801 * - description (string)
802 * - is_experimental (bool)
803 * - is_enabled (bool) (only if $include_enabled_info is passed as true)
804 *
805 * @param bool $include_experimental Include also experimental/work in progress features in the list.
806 * @param bool $include_enabled_info True to include the 'is_enabled' field in the returned features info.
807 * @returns array An array of information about existing features.
808 */
809 public function get_features( bool $include_experimental = false, bool $include_enabled_info = false ): array {
810 $features = $this->get_feature_definitions();
811
812 if ( ! $include_experimental ) {
813 $features = array_filter(
814 $features,
815 function ( $feature ) {
816 return ! $feature['is_experimental'];
817 }
818 );
819 }
820
821 if ( $include_enabled_info ) {
822 foreach ( array_keys( $features ) as $feature_id ) {
823 $is_enabled = false;
824 // For deprecated features, use the deprecated_value directly without triggering the deprecation notice.
825 // The deprecation notice should only fire for external code checking feature status, not for internal listing.
826 if ( ! empty( $features[ $feature_id ]['deprecated_since'] ) ) {
827 $is_enabled = (bool) ( $features[ $feature_id ]['deprecated_value'] ?? false );
828 } else {
829 $is_enabled = $this->feature_is_enabled( $feature_id );
830 }
831 $features[ $feature_id ]['is_enabled'] = $is_enabled;
832 }
833 }
834
835 // We're deprecating the product block editor feature in favor of a v3 coming out.
836 // We want to hide this setting in the UI for users that don't have it enabled.
837 // If users have it enabled, we won't hide it until they explicitly disable it.
838 if ( isset( $features['product_block_editor'] )
839 && ! $this->feature_is_enabled( 'product_block_editor' ) ) {
840 $features['product_block_editor']['disable_ui'] = true;
841 }
842
843 if ( isset( $features['wc-visual-attribute'] ) && ! wp_is_block_theme() ) {
844 $features['wc-visual-attribute']['disable_ui'] = true;
845 }
846
847 return $features;
848 }
849
850 /**
851 * Get the default plugin compatibility for a given feature.
852 *
853 * @param string $feature_id Feature id to check.
854 * @return string Either 'compatible' or 'incompatible'.
855 * @throws \InvalidArgumentException If the feature doesn't exist.
856 */
857 public function get_default_plugin_compatibility( string $feature_id ): string {
858 $feature = $this->get_feature_definition( $feature_id );
859 if ( null === $feature ) {
860 throw new \InvalidArgumentException( esc_html( "The WooCommerce feature '$feature_id' doesn't exist" ) );
861 }
862
863 $default_plugin_compatibility = $feature['default_plugin_compatibility'] ?? FeaturePluginCompatibility::COMPATIBLE;
864
865 // Filter below is only fired for backwards compatibility with (now removed) get_plugins_are_incompatible_by_default().
866 /**
867 * Filter to determine if plugins that don't declare compatibility nor incompatibility with a given feature
868 * are to be considered incompatible with that feature.
869 *
870 * @param bool $incompatible_by_default Default value, true if plugins are to be considered incompatible by default with the feature.
871 * @param string $feature_id The feature to check.
872 *
873 * @since 9.2.0
874 */
875 $incompatible_by_default = (bool) apply_filters( 'woocommerce_plugins_are_incompatible_with_feature_by_default', FeaturePluginCompatibility::INCOMPATIBLE === $default_plugin_compatibility, $feature_id );
876
877 return $incompatible_by_default ? FeaturePluginCompatibility::INCOMPATIBLE : FeaturePluginCompatibility::COMPATIBLE;
878 }
879
880 /**
881 * Get the definition array for a specific feature.
882 *
883 * @param string $feature_id Unique feature id.
884 * @return array|null The feature definition array, or null if the feature doesn't exist.
885 *
886 * @since 10.5.0
887 */
888 public function get_feature_definition( string $feature_id ): ?array {
889 return $this->get_feature_definitions()[ $feature_id ] ?? null;
890 }
891
892 /**
893 * Check if a given feature is currently enabled.
894 *
895 * Note: This method does not log deprecation notices for deprecated features.
896 * Deprecation logging is handled by FeaturesUtil::feature_is_enabled() which is the public API.
897 *
898 * @param string $feature_id Unique feature id.
899 * @return bool True if the feature is enabled, false if not or if the feature doesn't exist.
900 */
901 public function feature_is_enabled( string $feature_id ): bool {
902 $feature = $this->get_feature_definition( $feature_id );
903
904 if ( null === $feature ) {
905 return false;
906 }
907
908 // Handle deprecated features - return the backwards-compatible value.
909 if ( ! empty( $feature['deprecated_since'] ) ) {
910 return (bool) ( $feature['deprecated_value'] ?? false );
911 }
912
913 if ( $this->is_preview_email_improvements_enabled( $feature_id ) ) {
914 return true;
915 }
916
917 $default_value = $this->feature_is_enabled_by_default( $feature_id ) ? 'yes' : 'no';
918 $value = 'yes' === get_option( $this->feature_enable_option_name( $feature_id ), $default_value );
919 return $value;
920 }
921
922 /**
923 * Check if a given feature is enabled by default.
924 *
925 * @param string $feature_id Unique feature id.
926 * @return boolean TRUE if the feature is enabled by default, FALSE otherwise.
927 */
928 private function feature_is_enabled_by_default( string $feature_id ): bool {
929 $features = $this->get_feature_definitions();
930
931 return ! empty( $features[ $feature_id ]['enabled_by_default'] );
932 }
933
934 /**
935 * Change the enabled/disabled status of a feature.
936 *
937 * @param string $feature_id Unique feature id.
938 * @param bool $enable True to enable the feature, false to disable it.
939 * @return bool True on success, false if feature doesn't exist or the new value is the same as the old value.
940 */
941 public function change_feature_enable( string $feature_id, bool $enable ): bool {
942 if ( ! $this->feature_exists( $feature_id ) ) {
943 return false;
944 }
945
946 return update_option( $this->feature_enable_option_name( $feature_id ), $enable ? 'yes' : 'no', 'on' );
947 }
948
949 /**
950 * Declare (in)compatibility with a given feature for a given plugin.
951 *
952 * This method MUST be executed from inside a handler for the 'before_woocommerce_init' hook.
953 *
954 * The plugin name is expected to be in the form 'directory/file.php' and be one of the keys
955 * of the array returned by 'get_plugins', but this won't be checked. Plugins are expected to use
956 * FeaturesUtil::declare_compatibility instead, passing the full plugin file path instead of the plugin name.
957 *
958 * @param string $feature_id Unique feature id.
959 * @param string $plugin_file Plugin file path, either full or in the form 'directory/file.php'.
960 * @param bool $positive_compatibility True if the plugin declares being compatible with the feature, false if it declares being incompatible.
961 * @return bool True on success, false on error (feature doesn't exist or not inside the required hook).
962 * @throws \Exception A plugin attempted to declare itself as compatible and incompatible with a given feature at the same time.
963 */
964 public function declare_compatibility( string $feature_id, string $plugin_file, bool $positive_compatibility = true ): bool {
965 if ( ! $this->proxy->call_function( 'doing_action', 'before_woocommerce_init' ) ) {
966 $class_and_method = ( new \ReflectionClass( $this ) )->getShortName() . '::' . __FUNCTION__;
967 /* translators: 1: class::method 2: before_woocommerce_init */
968 $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' );
969 return false;
970 }
971 if ( ! $this->feature_exists( $feature_id ) ) {
972 return false;
973 }
974
975 if ( $this->lazy ) {
976 // Lazy mode: Queue to be normalized later.
977 $this->pending_declarations[] = array( $feature_id, $plugin_file, $positive_compatibility );
978 return true;
979 }
980
981 // Late call: Normalize and register immediately.
982 return $this->register_compatibility_internal( $feature_id, $plugin_file, $positive_compatibility );
983 }
984
985 /**
986 * Registers compatibility information internally for a given feature and plugin file.
987 *
988 * This method normalizes the plugin file path to a plugin ID, handles validation and logging for invalid plugins,
989 * and registers the compatibility data if valid.
990 * It updates the internal compatibility arrays, checks for conflicts (e.g., a plugin declaring both
991 * compatible and incompatible with the same feature), and throws an exception if a conflict is detected.
992 * Duplicate declarations (same compatibility type) are ignored.
993 *
994 * This is an internal helper method and should not be called directly.
995 *
996 * @internal For usage by WooCommerce core only. Backwards compatibility not guaranteed.
997 * @since 10.1.0
998 *
999 * @param string $feature_id Unique feature ID.
1000 * @param string $plugin_file Raw plugin file path (full or 'directory/file.php').
1001 * @param bool $positive_compatibility True if declaring compatibility, false if declaring incompatibility.
1002 * @return bool True on successful registration, false if the feature does not exist.
1003 * @throws \Exception If the plugin attempts to declare both compatibility and incompatibility for the same feature.
1004 */
1005 private function register_compatibility_internal( string $feature_id, string $plugin_file, bool $positive_compatibility ): bool {
1006 if ( ! $this->feature_exists( $feature_id ) ) {
1007 return false;
1008 }
1009
1010 // Normalize and validate plugin file.
1011 $plugin_id = $this->plugin_util->get_wp_plugin_id( $plugin_file );
1012 if ( ! $plugin_id ) {
1013 $logger = $this->proxy->call_function( 'wc_get_logger' );
1014 $logger->error( "FeaturesController: Invalid plugin file '{$plugin_file}' for feature '{$feature_id}'." );
1015 return false;
1016 }
1017
1018 // Register compatibility by plugin.
1019 ArrayUtil::ensure_key_is_array( $this->compatibility_info_by_plugin, $plugin_id );
1020
1021 $key = $positive_compatibility ? FeaturePluginCompatibility::COMPATIBLE : FeaturePluginCompatibility::INCOMPATIBLE;
1022 $opposite_key = $positive_compatibility ? FeaturePluginCompatibility::INCOMPATIBLE : FeaturePluginCompatibility::COMPATIBLE;
1023 ArrayUtil::ensure_key_is_array( $this->compatibility_info_by_plugin[ $plugin_id ], $key );
1024 ArrayUtil::ensure_key_is_array( $this->compatibility_info_by_plugin[ $plugin_id ], $opposite_key );
1025
1026 if ( in_array( $feature_id, $this->compatibility_info_by_plugin[ $plugin_id ][ $opposite_key ], true ) ) {
1027 throw new \Exception( esc_html( "Plugin $plugin_id is trying to declare itself as $key with the '$feature_id' feature, but it already declared itself as $opposite_key" ) );
1028 }
1029
1030 if ( ! in_array( $feature_id, $this->compatibility_info_by_plugin[ $plugin_id ][ $key ], true ) ) {
1031 $this->compatibility_info_by_plugin[ $plugin_id ][ $key ][] = $feature_id;
1032 }
1033
1034 // Register compatibility by feature.
1035 $key = $positive_compatibility ? FeaturePluginCompatibility::COMPATIBLE : FeaturePluginCompatibility::INCOMPATIBLE;
1036
1037 if ( ! in_array( $plugin_id, $this->compatibility_info_by_feature[ $feature_id ][ $key ], true ) ) {
1038 $this->compatibility_info_by_feature[ $feature_id ][ $key ][] = $plugin_id;
1039 }
1040
1041 return true;
1042 }
1043
1044 /**
1045 * Processes any pending compatibility declarations by normalizing plugin file paths
1046 * and registering them internally.
1047 *
1048 * This method is called lazily when compatibility information is queried (via
1049 * get_compatible_features_for_plugin() or get_compatible_plugins_for_feature()).
1050 * It resolves plugin IDs using PluginUtil and logs errors for unrecognized plugins.
1051 * Pending declarations are cleared after processing to avoid redundant work.
1052 *
1053 * @internal For usage by WooCommerce core only. Backwards compatibility not guaranteed.
1054 * @since 10.1.0
1055 * @return void
1056 */
1057 private function process_pending_declarations(): void {
1058 if ( empty( $this->pending_declarations ) ) {
1059 return;
1060 }
1061
1062 foreach ( $this->pending_declarations as $declaration ) {
1063 list( $feature_id, $plugin_file, $positive_compatibility ) = $declaration;
1064
1065 // Register internally.
1066 $this->register_compatibility_internal( $feature_id, $plugin_file, $positive_compatibility );
1067 }
1068
1069 $this->pending_declarations = array();
1070 $this->lazy = false;
1071 }
1072
1073 /**
1074 * Check whether a feature exists with a given id.
1075 *
1076 * @param string $feature_id The feature id to check.
1077 * @return bool True if the feature exists.
1078 */
1079 private function feature_exists( string $feature_id ): bool {
1080 $features = $this->get_feature_definitions();
1081
1082 return isset( $features[ $feature_id ] );
1083 }
1084
1085 /**
1086 * Get the ids of the features that a certain plugin has declared compatibility for.
1087 *
1088 * This method can't be called before the 'woocommerce_init' hook is fired.
1089 *
1090 * @param string $plugin_name Plugin name, in the form 'directory/file.php'.
1091 * @param bool $enabled_features_only True to return only names of enabled plugins.
1092 * @param bool $resolve_uncertain True to resolve the uncertain features to compatible or incompatible.
1093 * @return array An array having a 'compatible' and an 'incompatible' key, each holding an array of feature ids.
1094 */
1095 public function get_compatible_features_for_plugin( string $plugin_name, bool $enabled_features_only = false, bool $resolve_uncertain = false ): array {
1096 $this->process_pending_declarations();
1097 $this->verify_did_woocommerce_init( __FUNCTION__ );
1098
1099 $features = $this->get_feature_definitions();
1100
1101 if ( $enabled_features_only ) {
1102 $features = array_filter(
1103 $features,
1104 array( $this, 'feature_is_enabled' ),
1105 ARRAY_FILTER_USE_KEY
1106 );
1107 }
1108
1109 if ( ! isset( $this->compatibility_info_by_plugin[ $plugin_name ] ) ) {
1110 return array(
1111 FeaturePluginCompatibility::COMPATIBLE => array(),
1112 FeaturePluginCompatibility::INCOMPATIBLE => array(),
1113 FeaturePluginCompatibility::UNCERTAIN => array_keys( $features ),
1114 );
1115 }
1116
1117 $info = $this->compatibility_info_by_plugin[ $plugin_name ];
1118 $info[ FeaturePluginCompatibility::COMPATIBLE ] = array_values( array_intersect( array_keys( $features ), $info[ FeaturePluginCompatibility::COMPATIBLE ] ) );
1119 $info[ FeaturePluginCompatibility::INCOMPATIBLE ] = array_values( array_intersect( array_keys( $features ), $info[ FeaturePluginCompatibility::INCOMPATIBLE ] ) );
1120 $info[ FeaturePluginCompatibility::UNCERTAIN ] = array_values( array_diff( array_keys( $features ), $info[ FeaturePluginCompatibility::COMPATIBLE ], $info[ FeaturePluginCompatibility::INCOMPATIBLE ] ) );
1121
1122 if ( $resolve_uncertain ) {
1123 foreach ( $info[ FeaturePluginCompatibility::UNCERTAIN ] as $feature_id ) {
1124 $key = $this->get_default_plugin_compatibility( $feature_id );
1125 $info[ $key ][] = $feature_id;
1126 }
1127
1128 $info[ FeaturePluginCompatibility::UNCERTAIN ] = array();
1129 }
1130
1131 return $info;
1132 }
1133
1134 /**
1135 * Get the names of the plugins that have been declared compatible or incompatible with a given feature.
1136 *
1137 * @param string $feature_id Feature id.
1138 * @param bool $active_only True to return only active plugins.
1139 * @param bool $resolve_uncertain True to resolve the uncertain plugins to compatible or incompatible.
1140 * @return array An array having a 'compatible', an 'incompatible' and an 'uncertain' key, each holding an array of plugin names.
1141 */
1142 public function get_compatible_plugins_for_feature( string $feature_id, bool $active_only = false, bool $resolve_uncertain = false ): array {
1143 $this->process_pending_declarations();
1144 $this->verify_did_woocommerce_init( __FUNCTION__ );
1145
1146 $woo_aware_plugins = $this->plugin_util->get_woocommerce_aware_plugins( $active_only );
1147 if ( ! $this->feature_exists( $feature_id ) ) {
1148 return array(
1149 FeaturePluginCompatibility::COMPATIBLE => array(),
1150 FeaturePluginCompatibility::INCOMPATIBLE => array(),
1151 FeaturePluginCompatibility::UNCERTAIN => $woo_aware_plugins,
1152 );
1153 }
1154
1155 $info = $this->compatibility_info_by_feature[ $feature_id ];
1156 ArrayUtil::ensure_key_is_array( $info, FeaturePluginCompatibility::UNCERTAIN );
1157
1158 // Resolve uncertain plugin compatibility?
1159 $uncertain_plugins = array_values( array_diff( $woo_aware_plugins, $info[ FeaturePluginCompatibility::COMPATIBLE ], $info[ FeaturePluginCompatibility::INCOMPATIBLE ] ) );
1160 $key = $resolve_uncertain ? $this->get_default_plugin_compatibility( $feature_id ) : FeaturePluginCompatibility::UNCERTAIN;
1161 $info[ $key ] = array_merge( $info[ $key ], $uncertain_plugins );
1162
1163 return $info;
1164 }
1165
1166 /**
1167 * Check if the 'woocommerce_init' has run or is running, do a 'wc_doing_it_wrong' if not.
1168 *
1169 * @param string|null $function_name Name of the invoking method, if not null, 'wc_doing_it_wrong' will be invoked if 'woocommerce_init' has not run and is not running.
1170 *
1171 * @return bool True if 'woocommerce_init' has run or is running, false otherwise.
1172 */
1173 private function verify_did_woocommerce_init( ?string $function_name = null ): bool {
1174 if ( ! $this->proxy->call_function( 'did_action', 'woocommerce_init' ) &&
1175 ! $this->proxy->call_function( 'doing_action', 'woocommerce_init' ) ) {
1176 if ( ! is_null( $function_name ) ) {
1177 $class_and_method = ( new \ReflectionClass( $this ) )->getShortName() . '::' . $function_name;
1178 /* translators: 1: class::method 2: plugins_loaded */
1179 $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' );
1180 }
1181 return false;
1182 }
1183
1184 return true;
1185 }
1186
1187 /**
1188 * Get the name of the option that enables/disables a given feature.
1189 *
1190 * Note that it doesn't check if the feature actually exists. Instead it
1191 * defaults to "woocommerce_feature_{$feature_id}_enabled" if a different
1192 * name isn't specified in the feature registration.
1193 *
1194 * @param string $feature_id The id of the feature.
1195 * @return string The option that enables or disables the feature.
1196 */
1197 public function feature_enable_option_name( string $feature_id ): string {
1198 $features = $this->get_feature_definitions();
1199
1200 if ( ! empty( $features[ $feature_id ]['option_key'] ) ) {
1201 return $features[ $feature_id ]['option_key'];
1202 }
1203
1204 return "woocommerce_feature_{$feature_id}_enabled";
1205 }
1206
1207 /**
1208 * Check if the compatibility checks should be skipped for a given feature.
1209 *
1210 * @since 10.3.0
1211 *
1212 * @param string $feature_id The feature id to check.
1213 * @return bool TRUE if the compatibility checks should be skipped.
1214 */
1215 public function should_skip_compatibility_checks( string $feature_id ): bool {
1216 $features = $this->get_feature_definitions();
1217
1218 return ! empty( $features[ $feature_id ]['skip_compatibility_checks'] );
1219 }
1220
1221 /**
1222 * Sets a flag indicating that it's allowed to enable features for which incompatible plugins are active
1223 * from the WooCommerce feature settings page.
1224 */
1225 public function allow_enabling_features_with_incompatible_plugins(): void {
1226 $this->force_allow_enabling_features = true;
1227 }
1228
1229 /**
1230 * Sets a flag indicating that it's allowed to activate plugins for which incompatible features are enabled
1231 * from the WordPress plugins page.
1232 */
1233 public function allow_activating_plugins_with_incompatible_features(): void {
1234 $this->force_allow_enabling_plugins = true;
1235 }
1236
1237 /**
1238 * Adds our callbacks for the `updated_option` and `added_option` filter hooks.
1239 *
1240 * We delay adding these hooks until `init`, because both callbacks need to load our list of feature definitions,
1241 * and building that list requires translating various strings (which should not be done earlier than `init`).
1242 *
1243 * @return void
1244 *
1245 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1246 */
1247 public function start_listening_for_option_changes(): void {
1248 add_filter( 'updated_option', array( $this, 'process_updated_option' ), 999, 3 );
1249 add_filter( 'added_option', array( $this, 'process_added_option' ), 999, 3 );
1250 }
1251
1252 /**
1253 * Handler for the 'added_option' hook.
1254 *
1255 * It fires FEATURE_ENABLED_CHANGED_ACTION when a feature is enabled or disabled.
1256 *
1257 * @param string $option The option that has been created.
1258 * @param mixed $value The value of the option.
1259 *
1260 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1261 */
1262 public function process_added_option( string $option, $value ) {
1263 $this->process_updated_option( $option, false, $value );
1264 }
1265
1266 /**
1267 * Handler for the 'updated_option' hook.
1268 *
1269 * It fires FEATURE_ENABLED_CHANGED_ACTION when a feature is enabled or disabled.
1270 *
1271 * @param string $option The option that has been modified.
1272 * @param mixed $old_value The old value of the option.
1273 * @param mixed $value The new value of the option.
1274 *
1275 * @return void
1276 *
1277 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1278 */
1279 public function process_updated_option( string $option, $old_value, $value ) {
1280 $matches = array();
1281 $is_default_key = preg_match( '/^woocommerce_feature_([a-zA-Z0-9_]+)_enabled$/', $option, $matches );
1282 $features_with_custom_keys = array_filter(
1283 $this->get_feature_definitions(),
1284 function ( $feature ) {
1285 return ! empty( $feature['option_key'] );
1286 }
1287 );
1288 $custom_keys = wp_list_pluck( $features_with_custom_keys, 'option_key' );
1289
1290 if ( ! $is_default_key && ! in_array( $option, $custom_keys, true ) ) {
1291 return;
1292 }
1293
1294 if ( $value === $old_value ) {
1295 return;
1296 }
1297
1298 $feature_id = '';
1299 if ( $is_default_key ) {
1300 $feature_id = $matches[1];
1301 } elseif ( in_array( $option, $custom_keys, true ) ) {
1302 $feature_id = array_search( $option, $custom_keys, true );
1303 }
1304
1305 if ( ! $feature_id ) {
1306 return;
1307 }
1308
1309 WC_Tracks::record_event(
1310 self::FEATURE_ENABLED_CHANGED_ACTION,
1311 array(
1312 'feature_id' => $feature_id,
1313 'enabled' => $value,
1314 )
1315 );
1316
1317 /**
1318 * Action triggered when a feature is enabled or disabled (the value of the corresponding setting option is changed).
1319 *
1320 * @param string $feature_id The id of the feature.
1321 * @param bool $enabled True if the feature has been enabled, false if it has been disabled.
1322 *
1323 * @since 7.0.0
1324 */
1325 do_action( self::FEATURE_ENABLED_CHANGED_ACTION, $feature_id, 'yes' === $value );
1326 }
1327
1328 /**
1329 * Handler for the 'woocommerce_get_sections_advanced' hook,
1330 * it adds the "Features" section to the advanced settings page.
1331 *
1332 * @param array $sections The original sections array.
1333 * @return array The updated sections array.
1334 *
1335 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1336 */
1337 public function add_features_section( $sections ) {
1338 if ( ! isset( $sections['features'] ) ) {
1339 $sections['features'] = __( 'Features', 'woocommerce' );
1340 }
1341 return $sections;
1342 }
1343
1344 /**
1345 * Handler for the 'woocommerce_get_settings_advanced' hook,
1346 * it adds the settings UI for all the existing features.
1347 *
1348 * Note that the settings added via the 'woocommerce_settings_features' hook will be
1349 * displayed in the non-experimental features section.
1350 *
1351 * @param array $settings The existing settings for the corresponding settings section.
1352 * @param string $current_section The section to get the settings for.
1353 * @return array The updated settings array.
1354 *
1355 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1356 */
1357 public function add_feature_settings( $settings, $current_section ): array {
1358 if ( 'features' !== $current_section ) {
1359 return $settings;
1360 }
1361
1362 $feature_settings = array(
1363 array(
1364 'title' => __( 'Features', 'woocommerce' ),
1365 'type' => 'title',
1366 'desc' => __( 'Start using new features that are being progressively rolled out to improve the store management experience.', 'woocommerce' ),
1367 'id' => 'features_options',
1368 ),
1369 );
1370
1371 $features = $this->get_features( true );
1372
1373 $feature_ids = array_keys( $features );
1374 usort(
1375 $feature_ids,
1376 function ( $feature_id_a, $feature_id_b ) use ( $features ) {
1377 return ( $features[ $feature_id_b ]['order'] ?? 0 ) <=> ( $features[ $feature_id_a ]['order'] ?? 0 );
1378 }
1379 );
1380 $experimental_feature_ids = array_filter(
1381 $feature_ids,
1382 function ( $feature_id ) use ( $features ) {
1383 return $features[ $feature_id ]['is_experimental'] ?? false;
1384 }
1385 );
1386 $mature_feature_ids = array_diff( $feature_ids, $experimental_feature_ids );
1387 $feature_ids = array_merge( $mature_feature_ids, array( 'mature_features_end' ), $experimental_feature_ids );
1388
1389 foreach ( $feature_ids as $id ) {
1390 if ( 'mature_features_end' === $id ) {
1391 // phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment
1392 /**
1393 * Filter allowing to add additional settings to the WooCommerce Advanced - Features settings page.
1394 *
1395 * @param bool $disabled False.
1396 */
1397 $feature_settings = apply_filters( 'woocommerce_settings_features', $feature_settings );
1398 // phpcs:enable WooCommerce.Commenting.CommentHooks.MissingSinceComment
1399
1400 if ( ! empty( $experimental_feature_ids ) ) {
1401 $feature_settings[] = array(
1402 'type' => 'sectionend',
1403 'id' => 'features_options',
1404 );
1405
1406 $feature_settings[] = array(
1407 'title' => __( 'Experimental features', 'woocommerce' ),
1408 'type' => 'title',
1409 'desc' => __( 'These features are either experimental or incomplete, enable them at your own risk!', 'woocommerce' ),
1410 'id' => 'experimental_features_options',
1411 );
1412 }
1413 continue;
1414 }
1415
1416 if ( 'new_navigation' === $id && 'yes' !== get_option( $this->feature_enable_option_name( $id ), 'no' ) ) {
1417 continue;
1418 }
1419
1420 if ( isset( $features[ $id ]['disable_ui'] ) && $features[ $id ]['disable_ui'] ) {
1421 continue;
1422 }
1423
1424 $feature_settings[] = $this->get_setting_for_feature( $id, $features[ $id ] );
1425
1426 $additional_settings = $features[ $id ]['additional_settings'] ?? array();
1427 if ( count( $additional_settings ) > 0 ) {
1428 $feature_settings = array_merge( $feature_settings, $additional_settings );
1429 }
1430 }
1431
1432 $feature_settings[] = array(
1433 'type' => 'sectionend',
1434 'id' => empty( $experimental_feature_ids ) ? 'features_options' : 'experimental_features_options',
1435 );
1436
1437 if ( $this->verify_did_woocommerce_init() ) {
1438 // Allow feature setting properties to be determined dynamically just before being rendered.
1439 $feature_settings = array_map(
1440 function ( $feature_setting ) {
1441 foreach ( $feature_setting as $prop => $value ) {
1442 if ( is_callable( $value ) ) {
1443 $feature_setting[ $prop ] = call_user_func( $value );
1444 }
1445 }
1446
1447 return $feature_setting;
1448 },
1449 $feature_settings
1450 );
1451 }
1452
1453 return $feature_settings;
1454 }
1455
1456 /**
1457 * Get the parameters to display the setting enable/disable UI for a given feature.
1458 *
1459 * @param string $feature_id The feature id.
1460 * @param array $feature The feature parameters, as returned by get_features.
1461 * @return array The parameters to add to the settings array.
1462 */
1463 private function get_setting_for_feature( string $feature_id, array $feature ): array {
1464 $description = $feature['description'] ?? '';
1465 $disabled = false;
1466 $desc_tip = '';
1467 $tooltip = $feature['tooltip'] ?? '';
1468 $type = $feature['type'] ?? 'checkbox';
1469 $setting_definition = $feature['setting'] ?? array();
1470
1471 // phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment
1472 /**
1473 * Filter allowing WooCommerce Admin to be disabled.
1474 *
1475 * @param bool $disabled False.
1476 */
1477 $admin_features_disabled = apply_filters( 'woocommerce_admin_disabled', false );
1478 // phpcs:enable WooCommerce.Commenting.CommentHooks.MissingSinceComment
1479
1480 if ( ( 'analytics' === $feature_id || 'new_navigation' === $feature_id ) && $admin_features_disabled ) {
1481 $disabled = true;
1482 $desc_tip = __( 'WooCommerce Admin has been disabled', 'woocommerce' );
1483 } elseif ( 'new_navigation' === $feature_id ) {
1484 $update_text = sprintf(
1485 // translators: 1: line break tag.
1486 __(
1487 '%1$s This navigation will soon become unavailable while we make necessary improvements.
1488 If you turn it off now, you will not be able to turn it back on.',
1489 'woocommerce'
1490 ),
1491 '<br/>'
1492 );
1493
1494 $needs_update = version_compare( get_bloginfo( 'version' ), '5.6', '<' );
1495 if ( $needs_update && current_user_can( 'update_core' ) && current_user_can( 'update_php' ) ) {
1496 $update_text = sprintf(
1497 // translators: 1: line break tag, 2: open link to WordPress update link, 3: close link tag.
1498 __( '%1$s %2$sUpdate WordPress to enable the new navigation%3$s', 'woocommerce' ),
1499 '<br/>',
1500 '<a href="' . self_admin_url( 'update-core.php' ) . '" target="_blank">',
1501 '</a>'
1502 );
1503 $disabled = true;
1504 }
1505
1506 if ( ! empty( $update_text ) ) {
1507 $description .= $update_text;
1508 }
1509 }
1510
1511 if ( ! $this->should_skip_compatibility_checks( $feature_id ) && ! $disabled && $this->verify_did_woocommerce_init() ) {
1512 $plugin_info_for_feature = $this->get_compatible_plugins_for_feature( $feature_id, true );
1513 $desc_tip = $this->plugin_util->generate_incompatible_plugin_feature_warning( $feature_id, $plugin_info_for_feature );
1514 }
1515
1516 /**
1517 * Filter to customize the description tip that appears under the description of each feature in the features settings page.
1518 *
1519 * @since 7.1.0
1520 *
1521 * @param string $desc_tip The original description tip.
1522 * @param string $feature_id The id of the feature for which the description tip is being customized.
1523 * @param bool $disabled True if the UI currently prevents changing the enable/disable status of the feature.
1524 * @return string The new description tip to use.
1525 */
1526 $desc_tip = apply_filters( 'woocommerce_feature_description_tip', $desc_tip, $feature_id, $disabled );
1527
1528 $feature_setting_defaults = array(
1529 'title' => $feature['name'],
1530 'desc' => $description,
1531 'type' => $type,
1532 'id' => $this->feature_enable_option_name( $feature_id ),
1533 'disabled' => $disabled && ! $this->force_allow_enabling_features,
1534 'desc_tip' => $desc_tip,
1535 'tooltip' => $tooltip,
1536 'default' => $this->feature_is_enabled_by_default( $feature_id ) ? 'yes' : 'no',
1537 );
1538
1539 $feature_setting = wp_parse_args( $setting_definition, $feature_setting_defaults );
1540
1541 if ( ! empty( $feature['learn_more_url'] ) ) {
1542 $feature_setting['desc'] .= sprintf(
1543 '<span class="learn-more-link"><a href="%s" target="_blank">%s</a></span>',
1544 esc_attr( $feature['learn_more_url'] ),
1545 esc_html__( 'Learn more', 'woocommerce' )
1546 );
1547 }
1548
1549 /**
1550 * Allows to modify feature setting that will be used to render in the feature page.
1551 *
1552 * @param array $feature_setting The feature setting. Describes the feature:
1553 * - title: The title of the feature.
1554 * - desc: The description of the feature. Will be displayed under the title.
1555 * - type: The type of the feature. Could be any of supported settings types from `WC_Admin_Settings::output_fields`, but if it's anything other than checkbox or radio, it will need custom handling.
1556 * - id: The id of the feature. Will be used as the name of the setting.
1557 * - disabled: Whether the feature is disabled or not.
1558 * - desc_tip: The description tip of the feature. Will be displayed as a tooltip next to the description.
1559 * - tooltip: The tooltip of the feature. Will be displayed as a tooltip next to the name.
1560 * - default: The default value of the feature.
1561 * @param string $feature_id The id of the feature.
1562 * @since 8.0.0
1563 */
1564 return apply_filters( 'woocommerce_feature_setting', $feature_setting, $feature_id );
1565 }
1566
1567 /**
1568 * Handle the plugin deactivation hook.
1569 *
1570 * @param string $plugin_name Name of the plugin that has been deactivated.
1571 *
1572 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1573 */
1574 public function handle_plugin_deactivation( $plugin_name ): void {
1575 unset( $this->compatibility_info_by_plugin[ $plugin_name ] );
1576
1577 foreach ( array_keys( $this->compatibility_info_by_feature ) as $feature ) {
1578 $compatibles = $this->compatibility_info_by_feature[ $feature ][ FeaturePluginCompatibility::COMPATIBLE ];
1579 $this->compatibility_info_by_feature[ $feature ][ FeaturePluginCompatibility::COMPATIBLE ] = array_diff( $compatibles, array( $plugin_name ) );
1580
1581 $incompatibles = $this->compatibility_info_by_feature[ $feature ][ FeaturePluginCompatibility::INCOMPATIBLE ];
1582 $this->compatibility_info_by_feature[ $feature ][ FeaturePluginCompatibility::INCOMPATIBLE ] = array_diff( $incompatibles, array( $plugin_name ) );
1583 }
1584 }
1585
1586 /**
1587 * Handler for the all_plugins filter.
1588 *
1589 * Returns the list of plugins incompatible with a given plugin
1590 * if we are in the plugins page and the query string of the current request
1591 * looks like '?plugin_status=incompatible_with_feature&feature_id=<feature id>'.
1592 *
1593 * @param array $plugin_list The original list of plugins.
1594 *
1595 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1596 */
1597 public function filter_plugins_list( $plugin_list ): array {
1598 if ( ! $this->verify_did_woocommerce_init() ) {
1599 return $plugin_list;
1600 }
1601
1602 // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput
1603 if ( ! function_exists( 'get_current_screen' ) ||
1604 ( get_current_screen() && 'plugins' !== get_current_screen()->id ) ||
1605 'incompatible_with_feature' !== ArrayUtil::get_value_or_default( $_GET, 'plugin_status' ) ) {
1606 return $plugin_list;
1607 }
1608
1609 $feature_id = $_GET['feature_id'] ?? 'all';
1610 if ( 'all' !== $feature_id && ! $this->feature_exists( $feature_id ) ) {
1611 return $plugin_list;
1612 }
1613
1614 return $this->get_incompatible_plugins( $feature_id, $plugin_list );
1615 }
1616
1617 /**
1618 * Returns the list of plugins incompatible with a given feature.
1619 *
1620 * @param string $feature_id ID of the feature. Can also be `all` to denote all features.
1621 * @param array $plugin_list List of plugins to filter.
1622 *
1623 * @return array List of plugins incompatible with the given feature.
1624 */
1625 public function get_incompatible_plugins( $feature_id, $plugin_list ) {
1626 $incompatibles = array();
1627 $plugin_list = array_diff_key( $plugin_list, array_flip( $this->plugins_excluded_from_compatibility_ui ) );
1628 $feature_ids = 'all' === $feature_id ? array_keys( $this->get_feature_definitions() ) : array( $feature_id );
1629 $only_enabled_features = 'all' === $feature_id;
1630
1631 // phpcs:enable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput
1632 foreach ( array_keys( $plugin_list ) as $plugin_name ) {
1633 if ( ! $this->plugin_util->is_woocommerce_aware_plugin( $plugin_name ) || ! $this->proxy->call_function( 'is_plugin_active', $plugin_name ) ) {
1634 continue;
1635 }
1636
1637 $compatibility_info = $this->get_compatible_features_for_plugin( $plugin_name );
1638 foreach ( $feature_ids as $feature_id ) {
1639 $features_considered_incompatible = array_filter(
1640 $this->plugin_util->get_items_considered_incompatible( $feature_id, $compatibility_info ),
1641 $only_enabled_features ?
1642 fn( $id ) => $this->feature_is_enabled( $id ) && ! $this->should_skip_compatibility_checks( $id ) :
1643 fn( $id ) => ! $this->should_skip_compatibility_checks( $id )
1644 );
1645 if ( in_array( $feature_id, $features_considered_incompatible, true ) ) {
1646 $incompatibles[] = $plugin_name;
1647 }
1648 }
1649 }
1650
1651 return array_intersect_key( $plugin_list, array_flip( $incompatibles ) );
1652 }
1653
1654 /**
1655 * Handler for the admin_notices action.
1656 *
1657 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1658 */
1659 public function display_notices_in_plugins_page(): void {
1660 if ( ! $this->verify_did_woocommerce_init() ) {
1661 return;
1662 }
1663
1664 $feature_filter_description_shown = $this->maybe_display_current_feature_filter_description();
1665 if ( ! $feature_filter_description_shown ) {
1666 $this->maybe_display_feature_incompatibility_warning();
1667 }
1668 }
1669
1670 /**
1671 * Shows a warning when there are any incompatibility between active plugins and enabled features.
1672 * The warning is shown in on any admin screen except the plugins screen itself, since
1673 * there's already a "You are viewing plugins that are incompatible" notice.
1674 */
1675 private function maybe_display_feature_incompatibility_warning(): void {
1676 if ( ! current_user_can( 'activate_plugins' ) ) {
1677 return;
1678 }
1679
1680 $incompatible_plugins = false;
1681 $relevant_plugins = array_diff( $this->plugin_util->get_woocommerce_aware_plugins( true ), $this->plugins_excluded_from_compatibility_ui );
1682
1683 foreach ( $relevant_plugins as $plugin ) {
1684 $compatibility_info = $this->get_compatible_features_for_plugin( $plugin, true );
1685
1686 $incompatibles = array_filter( $compatibility_info[ FeaturePluginCompatibility::INCOMPATIBLE ], fn( $id ) => ! $this->should_skip_compatibility_checks( $id ) );
1687 if ( ! empty( $incompatibles ) ) {
1688 $incompatible_plugins = true;
1689 break;
1690 }
1691
1692 $uncertains = array_filter( $compatibility_info[ FeaturePluginCompatibility::UNCERTAIN ], fn( $id ) => ! $this->should_skip_compatibility_checks( $id ) );
1693 foreach ( $uncertains as $feature_id ) {
1694 if ( FeaturePluginCompatibility::COMPATIBLE !== $this->get_default_plugin_compatibility( $feature_id ) ) {
1695 $incompatible_plugins = true;
1696 break;
1697 }
1698 }
1699
1700 if ( $incompatible_plugins ) {
1701 break;
1702 }
1703 }
1704
1705 if ( ! $incompatible_plugins ) {
1706 return;
1707 }
1708
1709 $message = str_replace(
1710 '<a>',
1711 '<a href="' . esc_url( add_query_arg( array( 'plugin_status' => 'incompatible_with_feature' ), admin_url( 'plugins.php' ) ) ) . '">',
1712 __( 'WooCommerce has detected that some of your active plugins are incompatible with currently enabled WooCommerce features. Please <a>review the details</a>.', 'woocommerce' )
1713 );
1714
1715 // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
1716 ?>
1717 <div class="notice notice-error">
1718 <p><?php echo $message; ?></p>
1719 </div>
1720 <?php
1721 // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
1722 }
1723
1724 /**
1725 * Shows a "You are viewing the plugins that are incompatible with the X feature"
1726 * if we are in the plugins page and the query string of the current request
1727 * looks like '?plugin_status=incompatible_with_feature&feature_id=<feature id>'.
1728 */
1729 private function maybe_display_current_feature_filter_description(): bool {
1730 if ( 'plugins' !== get_current_screen()->id ) {
1731 return false;
1732 }
1733
1734 // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput
1735 $plugin_status = $_GET['plugin_status'] ?? '';
1736 $feature_id = $_GET['feature_id'] ?? '';
1737 // phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput
1738
1739 if ( 'incompatible_with_feature' !== $plugin_status ) {
1740 return false;
1741 }
1742
1743 $feature_id = ( '' === $feature_id ) ? 'all' : $feature_id;
1744
1745 if ( 'all' !== $feature_id && ! $this->feature_exists( $feature_id ) ) {
1746 return false;
1747 }
1748
1749 $features = $this->get_feature_definitions();
1750 $plugins_page_url = admin_url( 'plugins.php' );
1751 $features_page_url = $this->get_features_page_url();
1752
1753 $message =
1754 'all' === $feature_id
1755 ? __( 'You are viewing active plugins that are incompatible with currently enabled WooCommerce features.', 'woocommerce' )
1756 : sprintf(
1757 /* translators: %s is a feature name. */
1758 __( "You are viewing the active plugins that are incompatible with the '%s' feature.", 'woocommerce' ),
1759 $features[ $feature_id ]['name']
1760 );
1761
1762 $message .= '<br />';
1763 $message .= sprintf(
1764 __( "<a href='%1\$s'>View all plugins</a> - <a href='%2\$s'>Manage WooCommerce features</a>", 'woocommerce' ),
1765 $plugins_page_url,
1766 $features_page_url
1767 );
1768
1769 // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
1770 ?>
1771 <div class="notice notice-info">
1772 <p><?php echo $message; ?></p>
1773 </div>
1774 <?php
1775 // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
1776
1777 return true;
1778 }
1779
1780 /**
1781 * If the 'incompatible with features' plugin list is being rendered, invalidate existing cached plugin data.
1782 *
1783 * This heads off a problem in which WordPress's `get_plugins()` function may be called much earlier in the request
1784 * (by third party code, for example), the results of which are cached, and before WooCommerce can modify the list
1785 * to inject useful information of its own.
1786 *
1787 * @see https://github.com/woocommerce/woocommerce/issues/37343
1788 *
1789 * @return void
1790 *
1791 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1792 */
1793 public function maybe_invalidate_cached_plugin_data(): void {
1794 // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1795 if ( ( $_GET['plugin_status'] ?? '' ) === 'incompatible_with_feature' ) {
1796 wp_cache_delete( 'plugins', 'plugins' );
1797 }
1798 }
1799
1800 /**
1801 * Handler for the 'after_plugin_row' action.
1802 * Displays a "This plugin is incompatible with X features" notice if necessary.
1803 *
1804 * @param string $plugin_file The id of the plugin for which a row has been rendered in the plugins page.
1805 * @param array $plugin_data Plugin data, as returned by 'get_plugins'.
1806 *
1807 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1808 */
1809 public function handle_plugin_list_rows( $plugin_file, $plugin_data ) {
1810 global $wp_list_table;
1811
1812 if ( in_array( $plugin_file, $this->plugins_excluded_from_compatibility_ui, true ) ) {
1813 return;
1814 }
1815
1816 if ( 'incompatible_with_feature' !== ArrayUtil::get_value_or_default( $_GET, 'plugin_status' ) ) { // phpcs:ignore WordPress.Security.NonceVerification
1817 return;
1818 }
1819
1820 if ( is_null( $wp_list_table ) || ! $this->plugin_util->is_woocommerce_aware_plugin( $plugin_data ) ) {
1821 return;
1822 }
1823
1824 if ( ! $this->proxy->call_function( 'is_plugin_active', $plugin_file ) ) {
1825 return;
1826 }
1827
1828 $features = $this->get_feature_definitions();
1829 $feature_compatibility_info = $this->get_compatible_features_for_plugin( $plugin_file, true, true );
1830 $incompatible_features = $feature_compatibility_info[ FeaturePluginCompatibility::INCOMPATIBLE ];
1831 $incompatible_features = array_values(
1832 array_filter(
1833 $incompatible_features,
1834 function ( $feature_id ) {
1835 return ! $this->should_skip_compatibility_checks( $feature_id );
1836 }
1837 )
1838 );
1839
1840 $incompatible_features_count = count( $incompatible_features );
1841 if ( $incompatible_features_count > 0 ) {
1842 $columns_count = $wp_list_table->get_column_count();
1843 $is_active = true; // For now we are showing active plugins in the "Incompatible with..." view.
1844 $is_active_class = $is_active ? 'active' : 'inactive';
1845 $is_active_td_style = $is_active ? " style='border-left: 4px solid #72aee6;'" : '';
1846
1847 if ( 1 === $incompatible_features_count ) {
1848 $message = sprintf(
1849 /* translators: %s = printable plugin name */
1850 __( "⚠ This plugin is incompatible with the enabled WooCommerce feature '%s', it shouldn't be activated.", 'woocommerce' ),
1851 $features[ $incompatible_features[0] ]['name']
1852 );
1853 } elseif ( 2 === $incompatible_features_count ) {
1854 /* translators: %1\$s, %2\$s = printable plugin names */
1855 $message = sprintf(
1856 __( "⚠ This plugin is incompatible with the enabled WooCommerce features '%1\$s' and '%2\$s', it shouldn't be activated.", 'woocommerce' ),
1857 $features[ $incompatible_features[0] ]['name'],
1858 $features[ $incompatible_features[1] ]['name']
1859 );
1860 } else {
1861 /* translators: %1\$s, %2\$s = printable plugin names, %3\$d = plugins count */
1862 $message = sprintf(
1863 __( "⚠ This plugin is incompatible with the enabled WooCommerce features '%1\$s', '%2\$s' and %3\$d more, it shouldn't be activated.", 'woocommerce' ),
1864 $features[ $incompatible_features[0] ]['name'],
1865 $features[ $incompatible_features[1] ]['name'],
1866 $incompatible_features_count - 2
1867 );
1868 }
1869 $features_page_url = $this->get_features_page_url();
1870 $manage_features_message = __( 'Manage WooCommerce features', 'woocommerce' );
1871
1872 // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
1873 ?>
1874 <tr class='plugin-update-tr update <?php echo $is_active_class; ?>' data-plugin='<?php echo $plugin_file; ?>' data-plugin-row-type='feature-incomp-warn'>
1875 <td colspan='<?php echo $columns_count; ?>' class='plugin-update'<?php echo $is_active_td_style; ?>>
1876 <div class='notice inline notice-warning notice-alt'>
1877 <p>
1878 <?php echo $message; ?>
1879 <a href="<?php echo $features_page_url; ?>"><?php echo $manage_features_message; ?></a>
1880 </p>
1881 </div>
1882 </td>
1883 </tr>
1884 <?php
1885 // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
1886 }
1887 }
1888
1889 /**
1890 * Get the URL of the features settings page.
1891 *
1892 * @return string
1893 */
1894 public function get_features_page_url(): string {
1895 return admin_url( 'admin.php?page=wc-settings&tab=advanced&section=features' );
1896 }
1897
1898 /**
1899 * Fix for the HTML of the plugins list when there are feature-plugin incompatibility warnings.
1900 *
1901 * WordPress renders the plugin information rows in the plugins page in <tr> elements as follows:
1902 *
1903 * - If the plugin needs update, the <tr> will have an "update" class. This will prevent the lower
1904 * border line to be drawn. Later an additional <tr> with an "update available" warning will be rendered,
1905 * it will have a "plugin-update-tr" class which will draw the missing lower border line.
1906 * - Otherwise, the <tr> will be already drawn with the lower border line.
1907 *
1908 * This is a problem for our rendering of the "plugin is incompatible with X features" warning:
1909 *
1910 * - If the plugin info <tr> has "update", our <tr> will render nicely right after it; but then
1911 * our own "plugin-update-tr" class will draw an additional line before the "needs update" warning.
1912 * - If not, the plugin info <tr> will render its lower border line right before our compatibility info <tr>.
1913 *
1914 * This small script fixes this by adding the "update" class to the plugin info <tr> if it doesn't have it
1915 * (so no extra line before our <tr>), or removing 'plugin-update-tr' from our <tr> otherwise
1916 * (and then some extra manual tweaking of margins is needed).
1917 *
1918 * @param string $current_screen The current screen object.
1919 *
1920 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1921 */
1922 public function enqueue_script_to_fix_plugin_list_html( $current_screen ): void {
1923 if ( 'plugins' !== $current_screen->id ) {
1924 return;
1925 }
1926
1927 $handle = 'wc-features-fix-plugin-list-html';
1928 wp_register_script( $handle, '', array(), WC_VERSION, array( 'in_footer' => true ) );
1929 wp_enqueue_script( $handle );
1930 wp_add_inline_script(
1931 $handle,
1932 "
1933 const warningRows = document.querySelectorAll('tr[data-plugin-row-type=\"feature-incomp-warn\"]');
1934 for(const warningRow of warningRows) {
1935 const pluginName = warningRow.getAttribute('data-plugin');
1936 const pluginInfoRow = document.querySelector('tr.active[data-plugin=\"' + pluginName + '\"]:not(.plugin-update-tr), tr.inactive[data-plugin=\"' + pluginName + '\"]:not(.plugin-update-tr)');
1937 if(!pluginInfoRow) {
1938 continue;
1939 }
1940 if(pluginInfoRow.classList.contains('update')) {
1941 warningRow.classList.remove('plugin-update-tr');
1942 warningRow.querySelector('.notice').style.margin = '5px 10px 15px 30px';
1943 }
1944 else {
1945 pluginInfoRow.classList.add('update');
1946 }
1947 }
1948 "
1949 );
1950 }
1951
1952 /**
1953 * Handler for the 'views_plugins' hook that shows the links to the different views in the plugins page.
1954 * If we come from a "Manage incompatible plugins" in the features page we'll show just two views:
1955 * "All" (so that it's easy to go back to a known state) and "Incompatible with X".
1956 * We'll skip the rest of the views since the counts are wrong anyway, as we are modifying
1957 * the plugins list via the 'all_plugins' filter.
1958 *
1959 * @param array $views An array of view ids => view links.
1960 * @return string[] The actual views array to use.
1961 *
1962 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1963 */
1964 public function handle_plugins_page_views_list( $views ): array {
1965 // phpcs:disable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput
1966 if ( 'incompatible_with_feature' !== ArrayUtil::get_value_or_default( $_GET, 'plugin_status' ) ) {
1967 return $views;
1968 }
1969
1970 $feature_id = $_GET['feature_id'] ?? 'all';
1971 if ( 'all' !== $feature_id && ! $this->feature_exists( $feature_id ) ) {
1972 return $views;
1973 }
1974 // phpcs:enable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput
1975
1976 $all_items = get_plugins();
1977 $features = $this->get_feature_definitions();
1978
1979 $incompatible_plugins_count = count( $this->filter_plugins_list( $all_items ) );
1980 $incompatible_text =
1981 'all' === $feature_id
1982 ? __( 'Incompatible with WooCommerce features', 'woocommerce' )
1983 /* translators: %s = name of a WooCommerce feature */
1984 : sprintf( __( "Incompatible with '%s'", 'woocommerce' ), $features[ $feature_id ]['name'] );
1985 $incompatible_link = "<a href='plugins.php?plugin_status=incompatible_with_feature&feature_id={$feature_id}' class='current' aria-current='page'>{$incompatible_text} <span class='count'>({$incompatible_plugins_count})</span></a>";
1986
1987 $all_plugins_count = count( $all_items );
1988 $all_text = __( 'All', 'woocommerce' );
1989 $all_link = "<a href='plugins.php?plugin_status=all'>{$all_text} <span class='count'>({$all_plugins_count})</span></a>";
1990
1991 return array(
1992 'all' => $all_link,
1993 'incompatible_with_feature' => $incompatible_link,
1994 );
1995 }
1996
1997 /**
1998 * Set the feature nonce to be sent from client side.
1999 *
2000 * @param array $settings Component settings.
2001 *
2002 * @return array
2003 *
2004 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
2005 */
2006 public function set_change_feature_enable_nonce( $settings ) {
2007 $settings['_feature_nonce'] = wp_create_nonce( 'change_feature_enable' );
2008 return $settings;
2009 }
2010
2011 /**
2012 * Changes the feature given it's id, a toggle value and nonce as a query param.
2013 *
2014 * `/wp-admin/post.php?product_block_editor=1&_feature_nonce=1234`, 1 for on
2015 * `/wp-admin/post.php?product_block_editor=0&_feature_nonce=1234`, 0 for off
2016 *
2017 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
2018 */
2019 public function change_feature_enable_from_query_params(): void {
2020 if ( ! current_user_can( 'manage_woocommerce' ) ) {
2021 return;
2022 }
2023
2024 $is_feature_nonce_invalid = ( ! isset( $_GET['_feature_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_feature_nonce'] ) ), 'change_feature_enable' ) );
2025
2026 $query_params_to_remove = array( '_feature_nonce' );
2027
2028 foreach ( array_keys( $this->get_feature_definitions() ) as $feature_id ) {
2029 if ( isset( $_GET[ $feature_id ] ) && is_numeric( $_GET[ $feature_id ] ) ) {
2030 $value = absint( $_GET[ $feature_id ] );
2031
2032 if ( $is_feature_nonce_invalid ) {
2033 wp_die( esc_html__( 'Action failed. Please refresh the page and retry.', 'woocommerce' ) );
2034 return;
2035 }
2036
2037 if ( 1 === $value ) {
2038 $this->change_feature_enable( $feature_id, true );
2039 } elseif ( 0 === $value ) {
2040 $this->change_feature_enable( $feature_id, false );
2041 }
2042 $query_params_to_remove[] = $feature_id;
2043 }
2044 }
2045 if ( count( $query_params_to_remove ) > 1 && isset( $_SERVER['REQUEST_URI'] ) ) {
2046 // phpcs:disable WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
2047 wp_safe_redirect( remove_query_arg( $query_params_to_remove, $_SERVER['REQUEST_URI'] ) );
2048 }
2049 }
2050
2051 /**
2052 * Display the email improvements feedback notice to render CES modal in.
2053 *
2054 * @param string $feature_id The feature id.
2055 * @param bool $is_enabled Whether the feature is enabled.
2056 *
2057 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
2058 */
2059 public function display_email_improvements_feedback_notice( $feature_id, $is_enabled ): void {
2060 if ( 'email_improvements' === $feature_id && ! $is_enabled ) {
2061 set_transient( 'wc_settings_email_improvements_reverted', 'yes', 15 );
2062 add_action(
2063 'admin_notices',
2064 function () {
2065 echo '<div id="wc_settings_features_email_feedback_slotfill"></div>';
2066 }
2067 );
2068 }
2069 }
2070
2071 /**
2072 * Check if the email improvements feature is enabled in preview mode in Settings > Emails.
2073 * This is used to force the email improvements feature without affecting shoppers.
2074 *
2075 * @param string $feature_id The feature id.
2076 * @return bool Whether the email improvements feature is enabled in preview mode.
2077 */
2078 private function is_preview_email_improvements_enabled( string $feature_id ): bool {
2079 if ( 'email_improvements' !== $feature_id ) {
2080 return false;
2081 }
2082 /**
2083 * This filter is documented in templates/emails/email-styles.php
2084 *
2085 * @since 9.9.0
2086 * @param bool $is_email_preview Whether the email is being previewed.
2087 */
2088 $is_email_preview = apply_filters( 'woocommerce_is_email_preview', false );
2089 if ( $is_email_preview ) {
2090 return get_transient( EmailPreview::TRANSIENT_PREVIEW_EMAIL_IMPROVEMENTS ) === 'yes';
2091 }
2092 return false;
2093 }
2094 }
2095