PluginProbe ʕ •ᴥ•ʔ
WooCommerce / 10.9.1
WooCommerce v10.9.1
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 1 month ago
FeaturesController.php
2093 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 'enabled_by_default' => true,
623 'is_experimental' => true,
624 'disable_ui' => true,
625 'skip_compatibility_checks' => false,
626 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
627 ),
628 'rest_api_caching' => array(
629 'name' => __( 'REST API Caching', 'woocommerce' ),
630 'description' => sprintf(
631 /* translators: %1$s and %2$s are opening and closing <a> tags */
632 __( 'Enable backend caching and cache control headers for REST API responses via the <code>RestApiCache</code> trait. ⚙️ %1$sConfiguration%2$s', 'woocommerce' ),
633 '<a href="' . admin_url( 'admin.php?page=wc-settings&tab=advanced&section=rest_api_caching' ) . '">',
634 '</a>'
635 ),
636 'enabled_by_default' => false,
637 'is_experimental' => true,
638 'disable_ui' => false,
639 'skip_compatibility_checks' => true,
640 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
641 ),
642 'cart_save_for_later' => array(
643 'name' => __( 'Save for Later in Cart', 'woocommerce' ),
644 'description' => __(
645 'Let shoppers save cart items to a list to purchase later.',
646 'woocommerce'
647 ),
648 'is_experimental' => true,
649 'enabled_by_default' => false,
650 // Custom option_key as we expect this setting to move out of features to
651 // a cart/checkout settings section.
652 'option_key' => 'woocommerce_cart_save_for_later_enabled',
653 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
654 ),
655 'product_wishlist' => array(
656 'name' => __( 'Wishlists', 'woocommerce' ),
657 'description' => __(
658 'Let shoppers save products to a wishlist from product pages. Requires the Add to Cart + Options block on the single-product template.',
659 'woocommerce'
660 ),
661 'is_experimental' => true,
662 'enabled_by_default' => false,
663 'option_key' => 'woocommerce_product_wishlist_enabled',
664 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
665 ),
666 ProductCacheController::FEATURE_NAME => array(
667 'name' => __( 'Cache Product Objects', 'woocommerce' ),
668 'description' => __(
669 '[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.',
670 'woocommerce'
671 ),
672 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
673 'enabled_by_default' => false,
674 'is_experimental' => true,
675 'disable_ui' => false,
676 ),
677 );
678
679 if ( ! $tracking_enabled ) {
680 // Uncheck the remote logging feature when usage tracking is disabled.
681 $legacy_features['remote_logging']['setting']['value'] = 'no';
682 }
683
684 foreach ( $legacy_features as $slug => $definition ) {
685 $this->add_feature_definition( $slug, $definition['name'], $definition );
686 }
687
688 $this->init_compatibility_info_by_feature();
689 }
690
691 /**
692 * Initialize the compatibility_info_by_feature property after all the features have been added.
693 */
694 private function init_compatibility_info_by_feature() {
695 foreach ( array_keys( $this->features ) as $feature_id ) {
696 if ( ! isset( $this->compatibility_info_by_feature[ $feature_id ] ) ) {
697 $this->compatibility_info_by_feature[ $feature_id ] = array(
698 FeaturePluginCompatibility::COMPATIBLE => array(),
699 FeaturePluginCompatibility::INCOMPATIBLE => array(),
700 );
701 }
702 }
703 }
704
705 /**
706 * Generate the description for the MCP integration feature.
707 *
708 * @return string The feature description with conditional permalink warning and documentation link.
709 */
710 private function get_mcp_integration_description() {
711 $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' );
712
713 // Check permalink structure requirement.
714 $permalink_structure = get_option( 'permalink_structure' );
715 if ( empty( $permalink_structure ) ) {
716 $permalinks_url = admin_url( 'options-permalink.php' );
717 $permalink_warning = sprintf(
718 '<br><br><strong>%s:</strong> %s <a href="%s">%s</a>',
719 __( 'Configuration Required', 'woocommerce' ),
720 __( 'WordPress permalinks must be set to anything other than "Plain" for MCP to work.', 'woocommerce' ),
721 $permalinks_url,
722 __( 'Configure Permalinks', 'woocommerce' )
723 );
724 // Add documentation link to permalink warning.
725 $documentation_link = sprintf(
726 ' <a href="%s" target="_blank">%s</a>',
727 'https://github.com/woocommerce/woocommerce/blob/trunk/docs/features/mcp/README.md',
728 __( 'Learn more', 'woocommerce' )
729 );
730 return $base_description . $permalink_warning . $documentation_link;
731 }
732
733 // Add documentation link.
734 $documentation_link = sprintf(
735 ' <a href="%s" target="_blank">%s</a>',
736 'https://github.com/woocommerce/woocommerce/blob/trunk/docs/features/mcp/README.md',
737 __( 'Learn more', 'woocommerce' )
738 );
739
740 return $base_description . $documentation_link;
741 }
742
743 /**
744 * Function to trigger the (now deprecated) 'woocommerce_register_feature_definitions' hook.
745 *
746 * This function must execute immediately before the 'before_woocommerce_init'
747 * action is fired, so that feature compatibility declarations happening
748 * in that action find all the features properly declared already.
749 *
750 * @internal
751 */
752 public function register_additional_features() {
753 if ( $this->registered_additional_features_via_action ) {
754 return;
755 }
756
757 if ( empty( $this->features ) ) {
758 $this->init_feature_definitions();
759 }
760
761 /**
762 * The action for registering features.
763 *
764 * @since 8.3.0
765 *
766 * @param FeaturesController $features_controller The instance of FeaturesController.
767 *
768 * @deprecated 9.9.0 Features should be defined directly in get_feature_definitions.
769 */
770 do_action( 'woocommerce_register_feature_definitions', $this );
771
772 $this->init_compatibility_info_by_feature();
773
774 $this->registered_additional_features_via_action = true;
775 }
776
777 /**
778 * Initialize the class instance.
779 *
780 * @internal
781 *
782 * @param LegacyProxy $proxy The instance of LegacyProxy to use.
783 * @param PluginUtil $plugin_util The instance of PluginUtil to use.
784 */
785 final public function init( LegacyProxy $proxy, PluginUtil $plugin_util ) {
786 $this->proxy = $proxy;
787 $this->plugin_util = $plugin_util;
788
789 $this->plugins_excluded_from_compatibility_ui = $plugin_util->get_plugins_excluded_from_compatibility_ui();
790 }
791
792 /**
793 * Get all the existing WooCommerce features.
794 *
795 * Returns an associative array where keys are unique feature ids
796 * and values are arrays with these keys:
797 *
798 * - name (string)
799 * - description (string)
800 * - is_experimental (bool)
801 * - is_enabled (bool) (only if $include_enabled_info is passed as true)
802 *
803 * @param bool $include_experimental Include also experimental/work in progress features in the list.
804 * @param bool $include_enabled_info True to include the 'is_enabled' field in the returned features info.
805 * @returns array An array of information about existing features.
806 */
807 public function get_features( bool $include_experimental = false, bool $include_enabled_info = false ): array {
808 $features = $this->get_feature_definitions();
809
810 if ( ! $include_experimental ) {
811 $features = array_filter(
812 $features,
813 function ( $feature ) {
814 return ! $feature['is_experimental'];
815 }
816 );
817 }
818
819 if ( $include_enabled_info ) {
820 foreach ( array_keys( $features ) as $feature_id ) {
821 $is_enabled = false;
822 // For deprecated features, use the deprecated_value directly without triggering the deprecation notice.
823 // The deprecation notice should only fire for external code checking feature status, not for internal listing.
824 if ( ! empty( $features[ $feature_id ]['deprecated_since'] ) ) {
825 $is_enabled = (bool) ( $features[ $feature_id ]['deprecated_value'] ?? false );
826 } else {
827 $is_enabled = $this->feature_is_enabled( $feature_id );
828 }
829 $features[ $feature_id ]['is_enabled'] = $is_enabled;
830 }
831 }
832
833 // We're deprecating the product block editor feature in favor of a v3 coming out.
834 // We want to hide this setting in the UI for users that don't have it enabled.
835 // If users have it enabled, we won't hide it until they explicitly disable it.
836 if ( isset( $features['product_block_editor'] )
837 && ! $this->feature_is_enabled( 'product_block_editor' ) ) {
838 $features['product_block_editor']['disable_ui'] = true;
839 }
840
841 if ( isset( $features['wc-visual-attribute'] ) && ! wp_is_block_theme() ) {
842 $features['wc-visual-attribute']['disable_ui'] = true;
843 }
844
845 return $features;
846 }
847
848 /**
849 * Get the default plugin compatibility for a given feature.
850 *
851 * @param string $feature_id Feature id to check.
852 * @return string Either 'compatible' or 'incompatible'.
853 * @throws \InvalidArgumentException If the feature doesn't exist.
854 */
855 public function get_default_plugin_compatibility( string $feature_id ): string {
856 $feature = $this->get_feature_definition( $feature_id );
857 if ( null === $feature ) {
858 throw new \InvalidArgumentException( esc_html( "The WooCommerce feature '$feature_id' doesn't exist" ) );
859 }
860
861 $default_plugin_compatibility = $feature['default_plugin_compatibility'] ?? FeaturePluginCompatibility::COMPATIBLE;
862
863 // Filter below is only fired for backwards compatibility with (now removed) get_plugins_are_incompatible_by_default().
864 /**
865 * Filter to determine if plugins that don't declare compatibility nor incompatibility with a given feature
866 * are to be considered incompatible with that feature.
867 *
868 * @param bool $incompatible_by_default Default value, true if plugins are to be considered incompatible by default with the feature.
869 * @param string $feature_id The feature to check.
870 *
871 * @since 9.2.0
872 */
873 $incompatible_by_default = (bool) apply_filters( 'woocommerce_plugins_are_incompatible_with_feature_by_default', FeaturePluginCompatibility::INCOMPATIBLE === $default_plugin_compatibility, $feature_id );
874
875 return $incompatible_by_default ? FeaturePluginCompatibility::INCOMPATIBLE : FeaturePluginCompatibility::COMPATIBLE;
876 }
877
878 /**
879 * Get the definition array for a specific feature.
880 *
881 * @param string $feature_id Unique feature id.
882 * @return array|null The feature definition array, or null if the feature doesn't exist.
883 *
884 * @since 10.5.0
885 */
886 public function get_feature_definition( string $feature_id ): ?array {
887 return $this->get_feature_definitions()[ $feature_id ] ?? null;
888 }
889
890 /**
891 * Check if a given feature is currently enabled.
892 *
893 * Note: This method does not log deprecation notices for deprecated features.
894 * Deprecation logging is handled by FeaturesUtil::feature_is_enabled() which is the public API.
895 *
896 * @param string $feature_id Unique feature id.
897 * @return bool True if the feature is enabled, false if not or if the feature doesn't exist.
898 */
899 public function feature_is_enabled( string $feature_id ): bool {
900 $feature = $this->get_feature_definition( $feature_id );
901
902 if ( null === $feature ) {
903 return false;
904 }
905
906 // Handle deprecated features - return the backwards-compatible value.
907 if ( ! empty( $feature['deprecated_since'] ) ) {
908 return (bool) ( $feature['deprecated_value'] ?? false );
909 }
910
911 if ( $this->is_preview_email_improvements_enabled( $feature_id ) ) {
912 return true;
913 }
914
915 $default_value = $this->feature_is_enabled_by_default( $feature_id ) ? 'yes' : 'no';
916 $value = 'yes' === get_option( $this->feature_enable_option_name( $feature_id ), $default_value );
917 return $value;
918 }
919
920 /**
921 * Check if a given feature is enabled by default.
922 *
923 * @param string $feature_id Unique feature id.
924 * @return boolean TRUE if the feature is enabled by default, FALSE otherwise.
925 */
926 private function feature_is_enabled_by_default( string $feature_id ): bool {
927 $features = $this->get_feature_definitions();
928
929 return ! empty( $features[ $feature_id ]['enabled_by_default'] );
930 }
931
932 /**
933 * Change the enabled/disabled status of a feature.
934 *
935 * @param string $feature_id Unique feature id.
936 * @param bool $enable True to enable the feature, false to disable it.
937 * @return bool True on success, false if feature doesn't exist or the new value is the same as the old value.
938 */
939 public function change_feature_enable( string $feature_id, bool $enable ): bool {
940 if ( ! $this->feature_exists( $feature_id ) ) {
941 return false;
942 }
943
944 return update_option( $this->feature_enable_option_name( $feature_id ), $enable ? 'yes' : 'no', 'on' );
945 }
946
947 /**
948 * Declare (in)compatibility with a given feature for a given plugin.
949 *
950 * This method MUST be executed from inside a handler for the 'before_woocommerce_init' hook.
951 *
952 * The plugin name is expected to be in the form 'directory/file.php' and be one of the keys
953 * of the array returned by 'get_plugins', but this won't be checked. Plugins are expected to use
954 * FeaturesUtil::declare_compatibility instead, passing the full plugin file path instead of the plugin name.
955 *
956 * @param string $feature_id Unique feature id.
957 * @param string $plugin_file Plugin file path, either full or in the form 'directory/file.php'.
958 * @param bool $positive_compatibility True if the plugin declares being compatible with the feature, false if it declares being incompatible.
959 * @return bool True on success, false on error (feature doesn't exist or not inside the required hook).
960 * @throws \Exception A plugin attempted to declare itself as compatible and incompatible with a given feature at the same time.
961 */
962 public function declare_compatibility( string $feature_id, string $plugin_file, bool $positive_compatibility = true ): bool {
963 if ( ! $this->proxy->call_function( 'doing_action', 'before_woocommerce_init' ) ) {
964 $class_and_method = ( new \ReflectionClass( $this ) )->getShortName() . '::' . __FUNCTION__;
965 /* translators: 1: class::method 2: before_woocommerce_init */
966 $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' );
967 return false;
968 }
969 if ( ! $this->feature_exists( $feature_id ) ) {
970 return false;
971 }
972
973 if ( $this->lazy ) {
974 // Lazy mode: Queue to be normalized later.
975 $this->pending_declarations[] = array( $feature_id, $plugin_file, $positive_compatibility );
976 return true;
977 }
978
979 // Late call: Normalize and register immediately.
980 return $this->register_compatibility_internal( $feature_id, $plugin_file, $positive_compatibility );
981 }
982
983 /**
984 * Registers compatibility information internally for a given feature and plugin file.
985 *
986 * This method normalizes the plugin file path to a plugin ID, handles validation and logging for invalid plugins,
987 * and registers the compatibility data if valid.
988 * It updates the internal compatibility arrays, checks for conflicts (e.g., a plugin declaring both
989 * compatible and incompatible with the same feature), and throws an exception if a conflict is detected.
990 * Duplicate declarations (same compatibility type) are ignored.
991 *
992 * This is an internal helper method and should not be called directly.
993 *
994 * @internal For usage by WooCommerce core only. Backwards compatibility not guaranteed.
995 * @since 10.1.0
996 *
997 * @param string $feature_id Unique feature ID.
998 * @param string $plugin_file Raw plugin file path (full or 'directory/file.php').
999 * @param bool $positive_compatibility True if declaring compatibility, false if declaring incompatibility.
1000 * @return bool True on successful registration, false if the feature does not exist.
1001 * @throws \Exception If the plugin attempts to declare both compatibility and incompatibility for the same feature.
1002 */
1003 private function register_compatibility_internal( string $feature_id, string $plugin_file, bool $positive_compatibility ): bool {
1004 if ( ! $this->feature_exists( $feature_id ) ) {
1005 return false;
1006 }
1007
1008 // Normalize and validate plugin file.
1009 $plugin_id = $this->plugin_util->get_wp_plugin_id( $plugin_file );
1010 if ( ! $plugin_id ) {
1011 $logger = $this->proxy->call_function( 'wc_get_logger' );
1012 $logger->error( "FeaturesController: Invalid plugin file '{$plugin_file}' for feature '{$feature_id}'." );
1013 return false;
1014 }
1015
1016 // Register compatibility by plugin.
1017 ArrayUtil::ensure_key_is_array( $this->compatibility_info_by_plugin, $plugin_id );
1018
1019 $key = $positive_compatibility ? FeaturePluginCompatibility::COMPATIBLE : FeaturePluginCompatibility::INCOMPATIBLE;
1020 $opposite_key = $positive_compatibility ? FeaturePluginCompatibility::INCOMPATIBLE : FeaturePluginCompatibility::COMPATIBLE;
1021 ArrayUtil::ensure_key_is_array( $this->compatibility_info_by_plugin[ $plugin_id ], $key );
1022 ArrayUtil::ensure_key_is_array( $this->compatibility_info_by_plugin[ $plugin_id ], $opposite_key );
1023
1024 if ( in_array( $feature_id, $this->compatibility_info_by_plugin[ $plugin_id ][ $opposite_key ], true ) ) {
1025 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" ) );
1026 }
1027
1028 if ( ! in_array( $feature_id, $this->compatibility_info_by_plugin[ $plugin_id ][ $key ], true ) ) {
1029 $this->compatibility_info_by_plugin[ $plugin_id ][ $key ][] = $feature_id;
1030 }
1031
1032 // Register compatibility by feature.
1033 $key = $positive_compatibility ? FeaturePluginCompatibility::COMPATIBLE : FeaturePluginCompatibility::INCOMPATIBLE;
1034
1035 if ( ! in_array( $plugin_id, $this->compatibility_info_by_feature[ $feature_id ][ $key ], true ) ) {
1036 $this->compatibility_info_by_feature[ $feature_id ][ $key ][] = $plugin_id;
1037 }
1038
1039 return true;
1040 }
1041
1042 /**
1043 * Processes any pending compatibility declarations by normalizing plugin file paths
1044 * and registering them internally.
1045 *
1046 * This method is called lazily when compatibility information is queried (via
1047 * get_compatible_features_for_plugin() or get_compatible_plugins_for_feature()).
1048 * It resolves plugin IDs using PluginUtil and logs errors for unrecognized plugins.
1049 * Pending declarations are cleared after processing to avoid redundant work.
1050 *
1051 * @internal For usage by WooCommerce core only. Backwards compatibility not guaranteed.
1052 * @since 10.1.0
1053 * @return void
1054 */
1055 private function process_pending_declarations(): void {
1056 if ( empty( $this->pending_declarations ) ) {
1057 return;
1058 }
1059
1060 foreach ( $this->pending_declarations as $declaration ) {
1061 list( $feature_id, $plugin_file, $positive_compatibility ) = $declaration;
1062
1063 // Register internally.
1064 $this->register_compatibility_internal( $feature_id, $plugin_file, $positive_compatibility );
1065 }
1066
1067 $this->pending_declarations = array();
1068 $this->lazy = false;
1069 }
1070
1071 /**
1072 * Check whether a feature exists with a given id.
1073 *
1074 * @param string $feature_id The feature id to check.
1075 * @return bool True if the feature exists.
1076 */
1077 private function feature_exists( string $feature_id ): bool {
1078 $features = $this->get_feature_definitions();
1079
1080 return isset( $features[ $feature_id ] );
1081 }
1082
1083 /**
1084 * Get the ids of the features that a certain plugin has declared compatibility for.
1085 *
1086 * This method can't be called before the 'woocommerce_init' hook is fired.
1087 *
1088 * @param string $plugin_name Plugin name, in the form 'directory/file.php'.
1089 * @param bool $enabled_features_only True to return only names of enabled plugins.
1090 * @param bool $resolve_uncertain True to resolve the uncertain features to compatible or incompatible.
1091 * @return array An array having a 'compatible' and an 'incompatible' key, each holding an array of feature ids.
1092 */
1093 public function get_compatible_features_for_plugin( string $plugin_name, bool $enabled_features_only = false, bool $resolve_uncertain = false ): array {
1094 $this->process_pending_declarations();
1095 $this->verify_did_woocommerce_init( __FUNCTION__ );
1096
1097 $features = $this->get_feature_definitions();
1098
1099 if ( $enabled_features_only ) {
1100 $features = array_filter(
1101 $features,
1102 array( $this, 'feature_is_enabled' ),
1103 ARRAY_FILTER_USE_KEY
1104 );
1105 }
1106
1107 if ( ! isset( $this->compatibility_info_by_plugin[ $plugin_name ] ) ) {
1108 return array(
1109 FeaturePluginCompatibility::COMPATIBLE => array(),
1110 FeaturePluginCompatibility::INCOMPATIBLE => array(),
1111 FeaturePluginCompatibility::UNCERTAIN => array_keys( $features ),
1112 );
1113 }
1114
1115 $info = $this->compatibility_info_by_plugin[ $plugin_name ];
1116 $info[ FeaturePluginCompatibility::COMPATIBLE ] = array_values( array_intersect( array_keys( $features ), $info[ FeaturePluginCompatibility::COMPATIBLE ] ) );
1117 $info[ FeaturePluginCompatibility::INCOMPATIBLE ] = array_values( array_intersect( array_keys( $features ), $info[ FeaturePluginCompatibility::INCOMPATIBLE ] ) );
1118 $info[ FeaturePluginCompatibility::UNCERTAIN ] = array_values( array_diff( array_keys( $features ), $info[ FeaturePluginCompatibility::COMPATIBLE ], $info[ FeaturePluginCompatibility::INCOMPATIBLE ] ) );
1119
1120 if ( $resolve_uncertain ) {
1121 foreach ( $info[ FeaturePluginCompatibility::UNCERTAIN ] as $feature_id ) {
1122 $key = $this->get_default_plugin_compatibility( $feature_id );
1123 $info[ $key ][] = $feature_id;
1124 }
1125
1126 $info[ FeaturePluginCompatibility::UNCERTAIN ] = array();
1127 }
1128
1129 return $info;
1130 }
1131
1132 /**
1133 * Get the names of the plugins that have been declared compatible or incompatible with a given feature.
1134 *
1135 * @param string $feature_id Feature id.
1136 * @param bool $active_only True to return only active plugins.
1137 * @param bool $resolve_uncertain True to resolve the uncertain plugins to compatible or incompatible.
1138 * @return array An array having a 'compatible', an 'incompatible' and an 'uncertain' key, each holding an array of plugin names.
1139 */
1140 public function get_compatible_plugins_for_feature( string $feature_id, bool $active_only = false, bool $resolve_uncertain = false ): array {
1141 $this->process_pending_declarations();
1142 $this->verify_did_woocommerce_init( __FUNCTION__ );
1143
1144 $woo_aware_plugins = $this->plugin_util->get_woocommerce_aware_plugins( $active_only );
1145 if ( ! $this->feature_exists( $feature_id ) ) {
1146 return array(
1147 FeaturePluginCompatibility::COMPATIBLE => array(),
1148 FeaturePluginCompatibility::INCOMPATIBLE => array(),
1149 FeaturePluginCompatibility::UNCERTAIN => $woo_aware_plugins,
1150 );
1151 }
1152
1153 $info = $this->compatibility_info_by_feature[ $feature_id ];
1154 ArrayUtil::ensure_key_is_array( $info, FeaturePluginCompatibility::UNCERTAIN );
1155
1156 // Resolve uncertain plugin compatibility?
1157 $uncertain_plugins = array_values( array_diff( $woo_aware_plugins, $info[ FeaturePluginCompatibility::COMPATIBLE ], $info[ FeaturePluginCompatibility::INCOMPATIBLE ] ) );
1158 $key = $resolve_uncertain ? $this->get_default_plugin_compatibility( $feature_id ) : FeaturePluginCompatibility::UNCERTAIN;
1159 $info[ $key ] = array_merge( $info[ $key ], $uncertain_plugins );
1160
1161 return $info;
1162 }
1163
1164 /**
1165 * Check if the 'woocommerce_init' has run or is running, do a 'wc_doing_it_wrong' if not.
1166 *
1167 * @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.
1168 *
1169 * @return bool True if 'woocommerce_init' has run or is running, false otherwise.
1170 */
1171 private function verify_did_woocommerce_init( ?string $function_name = null ): bool {
1172 if ( ! $this->proxy->call_function( 'did_action', 'woocommerce_init' ) &&
1173 ! $this->proxy->call_function( 'doing_action', 'woocommerce_init' ) ) {
1174 if ( ! is_null( $function_name ) ) {
1175 $class_and_method = ( new \ReflectionClass( $this ) )->getShortName() . '::' . $function_name;
1176 /* translators: 1: class::method 2: plugins_loaded */
1177 $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' );
1178 }
1179 return false;
1180 }
1181
1182 return true;
1183 }
1184
1185 /**
1186 * Get the name of the option that enables/disables a given feature.
1187 *
1188 * Note that it doesn't check if the feature actually exists. Instead it
1189 * defaults to "woocommerce_feature_{$feature_id}_enabled" if a different
1190 * name isn't specified in the feature registration.
1191 *
1192 * @param string $feature_id The id of the feature.
1193 * @return string The option that enables or disables the feature.
1194 */
1195 public function feature_enable_option_name( string $feature_id ): string {
1196 $features = $this->get_feature_definitions();
1197
1198 if ( ! empty( $features[ $feature_id ]['option_key'] ) ) {
1199 return $features[ $feature_id ]['option_key'];
1200 }
1201
1202 return "woocommerce_feature_{$feature_id}_enabled";
1203 }
1204
1205 /**
1206 * Check if the compatibility checks should be skipped for a given feature.
1207 *
1208 * @since 10.3.0
1209 *
1210 * @param string $feature_id The feature id to check.
1211 * @return bool TRUE if the compatibility checks should be skipped.
1212 */
1213 public function should_skip_compatibility_checks( string $feature_id ): bool {
1214 $features = $this->get_feature_definitions();
1215
1216 return ! empty( $features[ $feature_id ]['skip_compatibility_checks'] );
1217 }
1218
1219 /**
1220 * Sets a flag indicating that it's allowed to enable features for which incompatible plugins are active
1221 * from the WooCommerce feature settings page.
1222 */
1223 public function allow_enabling_features_with_incompatible_plugins(): void {
1224 $this->force_allow_enabling_features = true;
1225 }
1226
1227 /**
1228 * Sets a flag indicating that it's allowed to activate plugins for which incompatible features are enabled
1229 * from the WordPress plugins page.
1230 */
1231 public function allow_activating_plugins_with_incompatible_features(): void {
1232 $this->force_allow_enabling_plugins = true;
1233 }
1234
1235 /**
1236 * Adds our callbacks for the `updated_option` and `added_option` filter hooks.
1237 *
1238 * We delay adding these hooks until `init`, because both callbacks need to load our list of feature definitions,
1239 * and building that list requires translating various strings (which should not be done earlier than `init`).
1240 *
1241 * @return void
1242 *
1243 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1244 */
1245 public function start_listening_for_option_changes(): void {
1246 add_filter( 'updated_option', array( $this, 'process_updated_option' ), 999, 3 );
1247 add_filter( 'added_option', array( $this, 'process_added_option' ), 999, 3 );
1248 }
1249
1250 /**
1251 * Handler for the 'added_option' hook.
1252 *
1253 * It fires FEATURE_ENABLED_CHANGED_ACTION when a feature is enabled or disabled.
1254 *
1255 * @param string $option The option that has been created.
1256 * @param mixed $value The value of the option.
1257 *
1258 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1259 */
1260 public function process_added_option( string $option, $value ) {
1261 $this->process_updated_option( $option, false, $value );
1262 }
1263
1264 /**
1265 * Handler for the 'updated_option' hook.
1266 *
1267 * It fires FEATURE_ENABLED_CHANGED_ACTION when a feature is enabled or disabled.
1268 *
1269 * @param string $option The option that has been modified.
1270 * @param mixed $old_value The old value of the option.
1271 * @param mixed $value The new value of the option.
1272 *
1273 * @return void
1274 *
1275 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1276 */
1277 public function process_updated_option( string $option, $old_value, $value ) {
1278 $matches = array();
1279 $is_default_key = preg_match( '/^woocommerce_feature_([a-zA-Z0-9_]+)_enabled$/', $option, $matches );
1280 $features_with_custom_keys = array_filter(
1281 $this->get_feature_definitions(),
1282 function ( $feature ) {
1283 return ! empty( $feature['option_key'] );
1284 }
1285 );
1286 $custom_keys = wp_list_pluck( $features_with_custom_keys, 'option_key' );
1287
1288 if ( ! $is_default_key && ! in_array( $option, $custom_keys, true ) ) {
1289 return;
1290 }
1291
1292 if ( $value === $old_value ) {
1293 return;
1294 }
1295
1296 $feature_id = '';
1297 if ( $is_default_key ) {
1298 $feature_id = $matches[1];
1299 } elseif ( in_array( $option, $custom_keys, true ) ) {
1300 $feature_id = array_search( $option, $custom_keys, true );
1301 }
1302
1303 if ( ! $feature_id ) {
1304 return;
1305 }
1306
1307 WC_Tracks::record_event(
1308 self::FEATURE_ENABLED_CHANGED_ACTION,
1309 array(
1310 'feature_id' => $feature_id,
1311 'enabled' => $value,
1312 )
1313 );
1314
1315 /**
1316 * Action triggered when a feature is enabled or disabled (the value of the corresponding setting option is changed).
1317 *
1318 * @param string $feature_id The id of the feature.
1319 * @param bool $enabled True if the feature has been enabled, false if it has been disabled.
1320 *
1321 * @since 7.0.0
1322 */
1323 do_action( self::FEATURE_ENABLED_CHANGED_ACTION, $feature_id, 'yes' === $value );
1324 }
1325
1326 /**
1327 * Handler for the 'woocommerce_get_sections_advanced' hook,
1328 * it adds the "Features" section to the advanced settings page.
1329 *
1330 * @param array $sections The original sections array.
1331 * @return array The updated sections array.
1332 *
1333 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1334 */
1335 public function add_features_section( $sections ) {
1336 if ( ! isset( $sections['features'] ) ) {
1337 $sections['features'] = __( 'Features', 'woocommerce' );
1338 }
1339 return $sections;
1340 }
1341
1342 /**
1343 * Handler for the 'woocommerce_get_settings_advanced' hook,
1344 * it adds the settings UI for all the existing features.
1345 *
1346 * Note that the settings added via the 'woocommerce_settings_features' hook will be
1347 * displayed in the non-experimental features section.
1348 *
1349 * @param array $settings The existing settings for the corresponding settings section.
1350 * @param string $current_section The section to get the settings for.
1351 * @return array The updated settings array.
1352 *
1353 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1354 */
1355 public function add_feature_settings( $settings, $current_section ): array {
1356 if ( 'features' !== $current_section ) {
1357 return $settings;
1358 }
1359
1360 $feature_settings = array(
1361 array(
1362 'title' => __( 'Features', 'woocommerce' ),
1363 'type' => 'title',
1364 'desc' => __( 'Start using new features that are being progressively rolled out to improve the store management experience.', 'woocommerce' ),
1365 'id' => 'features_options',
1366 ),
1367 );
1368
1369 $features = $this->get_features( true );
1370
1371 $feature_ids = array_keys( $features );
1372 usort(
1373 $feature_ids,
1374 function ( $feature_id_a, $feature_id_b ) use ( $features ) {
1375 return ( $features[ $feature_id_b ]['order'] ?? 0 ) <=> ( $features[ $feature_id_a ]['order'] ?? 0 );
1376 }
1377 );
1378 $experimental_feature_ids = array_filter(
1379 $feature_ids,
1380 function ( $feature_id ) use ( $features ) {
1381 return $features[ $feature_id ]['is_experimental'] ?? false;
1382 }
1383 );
1384 $mature_feature_ids = array_diff( $feature_ids, $experimental_feature_ids );
1385 $feature_ids = array_merge( $mature_feature_ids, array( 'mature_features_end' ), $experimental_feature_ids );
1386
1387 foreach ( $feature_ids as $id ) {
1388 if ( 'mature_features_end' === $id ) {
1389 // phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment
1390 /**
1391 * Filter allowing to add additional settings to the WooCommerce Advanced - Features settings page.
1392 *
1393 * @param bool $disabled False.
1394 */
1395 $feature_settings = apply_filters( 'woocommerce_settings_features', $feature_settings );
1396 // phpcs:enable WooCommerce.Commenting.CommentHooks.MissingSinceComment
1397
1398 if ( ! empty( $experimental_feature_ids ) ) {
1399 $feature_settings[] = array(
1400 'type' => 'sectionend',
1401 'id' => 'features_options',
1402 );
1403
1404 $feature_settings[] = array(
1405 'title' => __( 'Experimental features', 'woocommerce' ),
1406 'type' => 'title',
1407 'desc' => __( 'These features are either experimental or incomplete, enable them at your own risk!', 'woocommerce' ),
1408 'id' => 'experimental_features_options',
1409 );
1410 }
1411 continue;
1412 }
1413
1414 if ( 'new_navigation' === $id && 'yes' !== get_option( $this->feature_enable_option_name( $id ), 'no' ) ) {
1415 continue;
1416 }
1417
1418 if ( isset( $features[ $id ]['disable_ui'] ) && $features[ $id ]['disable_ui'] ) {
1419 continue;
1420 }
1421
1422 $feature_settings[] = $this->get_setting_for_feature( $id, $features[ $id ] );
1423
1424 $additional_settings = $features[ $id ]['additional_settings'] ?? array();
1425 if ( count( $additional_settings ) > 0 ) {
1426 $feature_settings = array_merge( $feature_settings, $additional_settings );
1427 }
1428 }
1429
1430 $feature_settings[] = array(
1431 'type' => 'sectionend',
1432 'id' => empty( $experimental_feature_ids ) ? 'features_options' : 'experimental_features_options',
1433 );
1434
1435 if ( $this->verify_did_woocommerce_init() ) {
1436 // Allow feature setting properties to be determined dynamically just before being rendered.
1437 $feature_settings = array_map(
1438 function ( $feature_setting ) {
1439 foreach ( $feature_setting as $prop => $value ) {
1440 if ( is_callable( $value ) ) {
1441 $feature_setting[ $prop ] = call_user_func( $value );
1442 }
1443 }
1444
1445 return $feature_setting;
1446 },
1447 $feature_settings
1448 );
1449 }
1450
1451 return $feature_settings;
1452 }
1453
1454 /**
1455 * Get the parameters to display the setting enable/disable UI for a given feature.
1456 *
1457 * @param string $feature_id The feature id.
1458 * @param array $feature The feature parameters, as returned by get_features.
1459 * @return array The parameters to add to the settings array.
1460 */
1461 private function get_setting_for_feature( string $feature_id, array $feature ): array {
1462 $description = $feature['description'] ?? '';
1463 $disabled = false;
1464 $desc_tip = '';
1465 $tooltip = $feature['tooltip'] ?? '';
1466 $type = $feature['type'] ?? 'checkbox';
1467 $setting_definition = $feature['setting'] ?? array();
1468
1469 // phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment
1470 /**
1471 * Filter allowing WooCommerce Admin to be disabled.
1472 *
1473 * @param bool $disabled False.
1474 */
1475 $admin_features_disabled = apply_filters( 'woocommerce_admin_disabled', false );
1476 // phpcs:enable WooCommerce.Commenting.CommentHooks.MissingSinceComment
1477
1478 if ( ( 'analytics' === $feature_id || 'new_navigation' === $feature_id ) && $admin_features_disabled ) {
1479 $disabled = true;
1480 $desc_tip = __( 'WooCommerce Admin has been disabled', 'woocommerce' );
1481 } elseif ( 'new_navigation' === $feature_id ) {
1482 $update_text = sprintf(
1483 // translators: 1: line break tag.
1484 __(
1485 '%1$s This navigation will soon become unavailable while we make necessary improvements.
1486 If you turn it off now, you will not be able to turn it back on.',
1487 'woocommerce'
1488 ),
1489 '<br/>'
1490 );
1491
1492 $needs_update = version_compare( get_bloginfo( 'version' ), '5.6', '<' );
1493 if ( $needs_update && current_user_can( 'update_core' ) && current_user_can( 'update_php' ) ) {
1494 $update_text = sprintf(
1495 // translators: 1: line break tag, 2: open link to WordPress update link, 3: close link tag.
1496 __( '%1$s %2$sUpdate WordPress to enable the new navigation%3$s', 'woocommerce' ),
1497 '<br/>',
1498 '<a href="' . self_admin_url( 'update-core.php' ) . '" target="_blank">',
1499 '</a>'
1500 );
1501 $disabled = true;
1502 }
1503
1504 if ( ! empty( $update_text ) ) {
1505 $description .= $update_text;
1506 }
1507 }
1508
1509 if ( ! $this->should_skip_compatibility_checks( $feature_id ) && ! $disabled && $this->verify_did_woocommerce_init() ) {
1510 $plugin_info_for_feature = $this->get_compatible_plugins_for_feature( $feature_id, true );
1511 $desc_tip = $this->plugin_util->generate_incompatible_plugin_feature_warning( $feature_id, $plugin_info_for_feature );
1512 }
1513
1514 /**
1515 * Filter to customize the description tip that appears under the description of each feature in the features settings page.
1516 *
1517 * @since 7.1.0
1518 *
1519 * @param string $desc_tip The original description tip.
1520 * @param string $feature_id The id of the feature for which the description tip is being customized.
1521 * @param bool $disabled True if the UI currently prevents changing the enable/disable status of the feature.
1522 * @return string The new description tip to use.
1523 */
1524 $desc_tip = apply_filters( 'woocommerce_feature_description_tip', $desc_tip, $feature_id, $disabled );
1525
1526 $feature_setting_defaults = array(
1527 'title' => $feature['name'],
1528 'desc' => $description,
1529 'type' => $type,
1530 'id' => $this->feature_enable_option_name( $feature_id ),
1531 'disabled' => $disabled && ! $this->force_allow_enabling_features,
1532 'desc_tip' => $desc_tip,
1533 'tooltip' => $tooltip,
1534 'default' => $this->feature_is_enabled_by_default( $feature_id ) ? 'yes' : 'no',
1535 );
1536
1537 $feature_setting = wp_parse_args( $setting_definition, $feature_setting_defaults );
1538
1539 if ( ! empty( $feature['learn_more_url'] ) ) {
1540 $feature_setting['desc'] .= sprintf(
1541 '<span class="learn-more-link"><a href="%s" target="_blank">%s</a></span>',
1542 esc_attr( $feature['learn_more_url'] ),
1543 esc_html__( 'Learn more', 'woocommerce' )
1544 );
1545 }
1546
1547 /**
1548 * Allows to modify feature setting that will be used to render in the feature page.
1549 *
1550 * @param array $feature_setting The feature setting. Describes the feature:
1551 * - title: The title of the feature.
1552 * - desc: The description of the feature. Will be displayed under the title.
1553 * - 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.
1554 * - id: The id of the feature. Will be used as the name of the setting.
1555 * - disabled: Whether the feature is disabled or not.
1556 * - desc_tip: The description tip of the feature. Will be displayed as a tooltip next to the description.
1557 * - tooltip: The tooltip of the feature. Will be displayed as a tooltip next to the name.
1558 * - default: The default value of the feature.
1559 * @param string $feature_id The id of the feature.
1560 * @since 8.0.0
1561 */
1562 return apply_filters( 'woocommerce_feature_setting', $feature_setting, $feature_id );
1563 }
1564
1565 /**
1566 * Handle the plugin deactivation hook.
1567 *
1568 * @param string $plugin_name Name of the plugin that has been deactivated.
1569 *
1570 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1571 */
1572 public function handle_plugin_deactivation( $plugin_name ): void {
1573 unset( $this->compatibility_info_by_plugin[ $plugin_name ] );
1574
1575 foreach ( array_keys( $this->compatibility_info_by_feature ) as $feature ) {
1576 $compatibles = $this->compatibility_info_by_feature[ $feature ][ FeaturePluginCompatibility::COMPATIBLE ];
1577 $this->compatibility_info_by_feature[ $feature ][ FeaturePluginCompatibility::COMPATIBLE ] = array_diff( $compatibles, array( $plugin_name ) );
1578
1579 $incompatibles = $this->compatibility_info_by_feature[ $feature ][ FeaturePluginCompatibility::INCOMPATIBLE ];
1580 $this->compatibility_info_by_feature[ $feature ][ FeaturePluginCompatibility::INCOMPATIBLE ] = array_diff( $incompatibles, array( $plugin_name ) );
1581 }
1582 }
1583
1584 /**
1585 * Handler for the all_plugins filter.
1586 *
1587 * Returns the list of plugins incompatible with a given plugin
1588 * if we are in the plugins page and the query string of the current request
1589 * looks like '?plugin_status=incompatible_with_feature&feature_id=<feature id>'.
1590 *
1591 * @param array $plugin_list The original list of plugins.
1592 *
1593 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1594 */
1595 public function filter_plugins_list( $plugin_list ): array {
1596 if ( ! $this->verify_did_woocommerce_init() ) {
1597 return $plugin_list;
1598 }
1599
1600 // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput
1601 if ( ! function_exists( 'get_current_screen' ) ||
1602 ( get_current_screen() && 'plugins' !== get_current_screen()->id ) ||
1603 'incompatible_with_feature' !== ArrayUtil::get_value_or_default( $_GET, 'plugin_status' ) ) {
1604 return $plugin_list;
1605 }
1606
1607 $feature_id = $_GET['feature_id'] ?? 'all';
1608 if ( 'all' !== $feature_id && ! $this->feature_exists( $feature_id ) ) {
1609 return $plugin_list;
1610 }
1611
1612 return $this->get_incompatible_plugins( $feature_id, $plugin_list );
1613 }
1614
1615 /**
1616 * Returns the list of plugins incompatible with a given feature.
1617 *
1618 * @param string $feature_id ID of the feature. Can also be `all` to denote all features.
1619 * @param array $plugin_list List of plugins to filter.
1620 *
1621 * @return array List of plugins incompatible with the given feature.
1622 */
1623 public function get_incompatible_plugins( $feature_id, $plugin_list ) {
1624 $incompatibles = array();
1625 $plugin_list = array_diff_key( $plugin_list, array_flip( $this->plugins_excluded_from_compatibility_ui ) );
1626 $feature_ids = 'all' === $feature_id ? array_keys( $this->get_feature_definitions() ) : array( $feature_id );
1627 $only_enabled_features = 'all' === $feature_id;
1628
1629 // phpcs:enable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput
1630 foreach ( array_keys( $plugin_list ) as $plugin_name ) {
1631 if ( ! $this->plugin_util->is_woocommerce_aware_plugin( $plugin_name ) || ! $this->proxy->call_function( 'is_plugin_active', $plugin_name ) ) {
1632 continue;
1633 }
1634
1635 $compatibility_info = $this->get_compatible_features_for_plugin( $plugin_name );
1636 foreach ( $feature_ids as $feature_id ) {
1637 $features_considered_incompatible = array_filter(
1638 $this->plugin_util->get_items_considered_incompatible( $feature_id, $compatibility_info ),
1639 $only_enabled_features ?
1640 fn( $id ) => $this->feature_is_enabled( $id ) && ! $this->should_skip_compatibility_checks( $id ) :
1641 fn( $id ) => ! $this->should_skip_compatibility_checks( $id )
1642 );
1643 if ( in_array( $feature_id, $features_considered_incompatible, true ) ) {
1644 $incompatibles[] = $plugin_name;
1645 }
1646 }
1647 }
1648
1649 return array_intersect_key( $plugin_list, array_flip( $incompatibles ) );
1650 }
1651
1652 /**
1653 * Handler for the admin_notices action.
1654 *
1655 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1656 */
1657 public function display_notices_in_plugins_page(): void {
1658 if ( ! $this->verify_did_woocommerce_init() ) {
1659 return;
1660 }
1661
1662 $feature_filter_description_shown = $this->maybe_display_current_feature_filter_description();
1663 if ( ! $feature_filter_description_shown ) {
1664 $this->maybe_display_feature_incompatibility_warning();
1665 }
1666 }
1667
1668 /**
1669 * Shows a warning when there are any incompatibility between active plugins and enabled features.
1670 * The warning is shown in on any admin screen except the plugins screen itself, since
1671 * there's already a "You are viewing plugins that are incompatible" notice.
1672 */
1673 private function maybe_display_feature_incompatibility_warning(): void {
1674 if ( ! current_user_can( 'activate_plugins' ) ) {
1675 return;
1676 }
1677
1678 $incompatible_plugins = false;
1679 $relevant_plugins = array_diff( $this->plugin_util->get_woocommerce_aware_plugins( true ), $this->plugins_excluded_from_compatibility_ui );
1680
1681 foreach ( $relevant_plugins as $plugin ) {
1682 $compatibility_info = $this->get_compatible_features_for_plugin( $plugin, true );
1683
1684 $incompatibles = array_filter( $compatibility_info[ FeaturePluginCompatibility::INCOMPATIBLE ], fn( $id ) => ! $this->should_skip_compatibility_checks( $id ) );
1685 if ( ! empty( $incompatibles ) ) {
1686 $incompatible_plugins = true;
1687 break;
1688 }
1689
1690 $uncertains = array_filter( $compatibility_info[ FeaturePluginCompatibility::UNCERTAIN ], fn( $id ) => ! $this->should_skip_compatibility_checks( $id ) );
1691 foreach ( $uncertains as $feature_id ) {
1692 if ( FeaturePluginCompatibility::COMPATIBLE !== $this->get_default_plugin_compatibility( $feature_id ) ) {
1693 $incompatible_plugins = true;
1694 break;
1695 }
1696 }
1697
1698 if ( $incompatible_plugins ) {
1699 break;
1700 }
1701 }
1702
1703 if ( ! $incompatible_plugins ) {
1704 return;
1705 }
1706
1707 $message = str_replace(
1708 '<a>',
1709 '<a href="' . esc_url( add_query_arg( array( 'plugin_status' => 'incompatible_with_feature' ), admin_url( 'plugins.php' ) ) ) . '">',
1710 __( 'WooCommerce has detected that some of your active plugins are incompatible with currently enabled WooCommerce features. Please <a>review the details</a>.', 'woocommerce' )
1711 );
1712
1713 // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
1714 ?>
1715 <div class="notice notice-error">
1716 <p><?php echo $message; ?></p>
1717 </div>
1718 <?php
1719 // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
1720 }
1721
1722 /**
1723 * Shows a "You are viewing the plugins that are incompatible with the X feature"
1724 * if we are in the plugins page and the query string of the current request
1725 * looks like '?plugin_status=incompatible_with_feature&feature_id=<feature id>'.
1726 */
1727 private function maybe_display_current_feature_filter_description(): bool {
1728 if ( 'plugins' !== get_current_screen()->id ) {
1729 return false;
1730 }
1731
1732 // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput
1733 $plugin_status = $_GET['plugin_status'] ?? '';
1734 $feature_id = $_GET['feature_id'] ?? '';
1735 // phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput
1736
1737 if ( 'incompatible_with_feature' !== $plugin_status ) {
1738 return false;
1739 }
1740
1741 $feature_id = ( '' === $feature_id ) ? 'all' : $feature_id;
1742
1743 if ( 'all' !== $feature_id && ! $this->feature_exists( $feature_id ) ) {
1744 return false;
1745 }
1746
1747 $features = $this->get_feature_definitions();
1748 $plugins_page_url = admin_url( 'plugins.php' );
1749 $features_page_url = $this->get_features_page_url();
1750
1751 $message =
1752 'all' === $feature_id
1753 ? __( 'You are viewing active plugins that are incompatible with currently enabled WooCommerce features.', 'woocommerce' )
1754 : sprintf(
1755 /* translators: %s is a feature name. */
1756 __( "You are viewing the active plugins that are incompatible with the '%s' feature.", 'woocommerce' ),
1757 $features[ $feature_id ]['name']
1758 );
1759
1760 $message .= '<br />';
1761 $message .= sprintf(
1762 __( "<a href='%1\$s'>View all plugins</a> - <a href='%2\$s'>Manage WooCommerce features</a>", 'woocommerce' ),
1763 $plugins_page_url,
1764 $features_page_url
1765 );
1766
1767 // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
1768 ?>
1769 <div class="notice notice-info">
1770 <p><?php echo $message; ?></p>
1771 </div>
1772 <?php
1773 // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
1774
1775 return true;
1776 }
1777
1778 /**
1779 * If the 'incompatible with features' plugin list is being rendered, invalidate existing cached plugin data.
1780 *
1781 * This heads off a problem in which WordPress's `get_plugins()` function may be called much earlier in the request
1782 * (by third party code, for example), the results of which are cached, and before WooCommerce can modify the list
1783 * to inject useful information of its own.
1784 *
1785 * @see https://github.com/woocommerce/woocommerce/issues/37343
1786 *
1787 * @return void
1788 *
1789 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1790 */
1791 public function maybe_invalidate_cached_plugin_data(): void {
1792 // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1793 if ( ( $_GET['plugin_status'] ?? '' ) === 'incompatible_with_feature' ) {
1794 wp_cache_delete( 'plugins', 'plugins' );
1795 }
1796 }
1797
1798 /**
1799 * Handler for the 'after_plugin_row' action.
1800 * Displays a "This plugin is incompatible with X features" notice if necessary.
1801 *
1802 * @param string $plugin_file The id of the plugin for which a row has been rendered in the plugins page.
1803 * @param array $plugin_data Plugin data, as returned by 'get_plugins'.
1804 *
1805 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1806 */
1807 public function handle_plugin_list_rows( $plugin_file, $plugin_data ) {
1808 global $wp_list_table;
1809
1810 if ( in_array( $plugin_file, $this->plugins_excluded_from_compatibility_ui, true ) ) {
1811 return;
1812 }
1813
1814 if ( 'incompatible_with_feature' !== ArrayUtil::get_value_or_default( $_GET, 'plugin_status' ) ) { // phpcs:ignore WordPress.Security.NonceVerification
1815 return;
1816 }
1817
1818 if ( is_null( $wp_list_table ) || ! $this->plugin_util->is_woocommerce_aware_plugin( $plugin_data ) ) {
1819 return;
1820 }
1821
1822 if ( ! $this->proxy->call_function( 'is_plugin_active', $plugin_file ) ) {
1823 return;
1824 }
1825
1826 $features = $this->get_feature_definitions();
1827 $feature_compatibility_info = $this->get_compatible_features_for_plugin( $plugin_file, true, true );
1828 $incompatible_features = $feature_compatibility_info[ FeaturePluginCompatibility::INCOMPATIBLE ];
1829 $incompatible_features = array_values(
1830 array_filter(
1831 $incompatible_features,
1832 function ( $feature_id ) {
1833 return ! $this->should_skip_compatibility_checks( $feature_id );
1834 }
1835 )
1836 );
1837
1838 $incompatible_features_count = count( $incompatible_features );
1839 if ( $incompatible_features_count > 0 ) {
1840 $columns_count = $wp_list_table->get_column_count();
1841 $is_active = true; // For now we are showing active plugins in the "Incompatible with..." view.
1842 $is_active_class = $is_active ? 'active' : 'inactive';
1843 $is_active_td_style = $is_active ? " style='border-left: 4px solid #72aee6;'" : '';
1844
1845 if ( 1 === $incompatible_features_count ) {
1846 $message = sprintf(
1847 /* translators: %s = printable plugin name */
1848 __( "⚠ This plugin is incompatible with the enabled WooCommerce feature '%s', it shouldn't be activated.", 'woocommerce' ),
1849 $features[ $incompatible_features[0] ]['name']
1850 );
1851 } elseif ( 2 === $incompatible_features_count ) {
1852 /* translators: %1\$s, %2\$s = printable plugin names */
1853 $message = sprintf(
1854 __( "⚠ This plugin is incompatible with the enabled WooCommerce features '%1\$s' and '%2\$s', it shouldn't be activated.", 'woocommerce' ),
1855 $features[ $incompatible_features[0] ]['name'],
1856 $features[ $incompatible_features[1] ]['name']
1857 );
1858 } else {
1859 /* translators: %1\$s, %2\$s = printable plugin names, %3\$d = plugins count */
1860 $message = sprintf(
1861 __( "⚠ This plugin is incompatible with the enabled WooCommerce features '%1\$s', '%2\$s' and %3\$d more, it shouldn't be activated.", 'woocommerce' ),
1862 $features[ $incompatible_features[0] ]['name'],
1863 $features[ $incompatible_features[1] ]['name'],
1864 $incompatible_features_count - 2
1865 );
1866 }
1867 $features_page_url = $this->get_features_page_url();
1868 $manage_features_message = __( 'Manage WooCommerce features', 'woocommerce' );
1869
1870 // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
1871 ?>
1872 <tr class='plugin-update-tr update <?php echo $is_active_class; ?>' data-plugin='<?php echo $plugin_file; ?>' data-plugin-row-type='feature-incomp-warn'>
1873 <td colspan='<?php echo $columns_count; ?>' class='plugin-update'<?php echo $is_active_td_style; ?>>
1874 <div class='notice inline notice-warning notice-alt'>
1875 <p>
1876 <?php echo $message; ?>
1877 <a href="<?php echo $features_page_url; ?>"><?php echo $manage_features_message; ?></a>
1878 </p>
1879 </div>
1880 </td>
1881 </tr>
1882 <?php
1883 // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
1884 }
1885 }
1886
1887 /**
1888 * Get the URL of the features settings page.
1889 *
1890 * @return string
1891 */
1892 public function get_features_page_url(): string {
1893 return admin_url( 'admin.php?page=wc-settings&tab=advanced&section=features' );
1894 }
1895
1896 /**
1897 * Fix for the HTML of the plugins list when there are feature-plugin incompatibility warnings.
1898 *
1899 * WordPress renders the plugin information rows in the plugins page in <tr> elements as follows:
1900 *
1901 * - If the plugin needs update, the <tr> will have an "update" class. This will prevent the lower
1902 * border line to be drawn. Later an additional <tr> with an "update available" warning will be rendered,
1903 * it will have a "plugin-update-tr" class which will draw the missing lower border line.
1904 * - Otherwise, the <tr> will be already drawn with the lower border line.
1905 *
1906 * This is a problem for our rendering of the "plugin is incompatible with X features" warning:
1907 *
1908 * - If the plugin info <tr> has "update", our <tr> will render nicely right after it; but then
1909 * our own "plugin-update-tr" class will draw an additional line before the "needs update" warning.
1910 * - If not, the plugin info <tr> will render its lower border line right before our compatibility info <tr>.
1911 *
1912 * This small script fixes this by adding the "update" class to the plugin info <tr> if it doesn't have it
1913 * (so no extra line before our <tr>), or removing 'plugin-update-tr' from our <tr> otherwise
1914 * (and then some extra manual tweaking of margins is needed).
1915 *
1916 * @param string $current_screen The current screen object.
1917 *
1918 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1919 */
1920 public function enqueue_script_to_fix_plugin_list_html( $current_screen ): void {
1921 if ( 'plugins' !== $current_screen->id ) {
1922 return;
1923 }
1924
1925 $handle = 'wc-features-fix-plugin-list-html';
1926 wp_register_script( $handle, '', array(), WC_VERSION, array( 'in_footer' => true ) );
1927 wp_enqueue_script( $handle );
1928 wp_add_inline_script(
1929 $handle,
1930 "
1931 const warningRows = document.querySelectorAll('tr[data-plugin-row-type=\"feature-incomp-warn\"]');
1932 for(const warningRow of warningRows) {
1933 const pluginName = warningRow.getAttribute('data-plugin');
1934 const pluginInfoRow = document.querySelector('tr.active[data-plugin=\"' + pluginName + '\"]:not(.plugin-update-tr), tr.inactive[data-plugin=\"' + pluginName + '\"]:not(.plugin-update-tr)');
1935 if(!pluginInfoRow) {
1936 continue;
1937 }
1938 if(pluginInfoRow.classList.contains('update')) {
1939 warningRow.classList.remove('plugin-update-tr');
1940 warningRow.querySelector('.notice').style.margin = '5px 10px 15px 30px';
1941 }
1942 else {
1943 pluginInfoRow.classList.add('update');
1944 }
1945 }
1946 "
1947 );
1948 }
1949
1950 /**
1951 * Handler for the 'views_plugins' hook that shows the links to the different views in the plugins page.
1952 * If we come from a "Manage incompatible plugins" in the features page we'll show just two views:
1953 * "All" (so that it's easy to go back to a known state) and "Incompatible with X".
1954 * We'll skip the rest of the views since the counts are wrong anyway, as we are modifying
1955 * the plugins list via the 'all_plugins' filter.
1956 *
1957 * @param array $views An array of view ids => view links.
1958 * @return string[] The actual views array to use.
1959 *
1960 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1961 */
1962 public function handle_plugins_page_views_list( $views ): array {
1963 // phpcs:disable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput
1964 if ( 'incompatible_with_feature' !== ArrayUtil::get_value_or_default( $_GET, 'plugin_status' ) ) {
1965 return $views;
1966 }
1967
1968 $feature_id = $_GET['feature_id'] ?? 'all';
1969 if ( 'all' !== $feature_id && ! $this->feature_exists( $feature_id ) ) {
1970 return $views;
1971 }
1972 // phpcs:enable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput
1973
1974 $all_items = get_plugins();
1975 $features = $this->get_feature_definitions();
1976
1977 $incompatible_plugins_count = count( $this->filter_plugins_list( $all_items ) );
1978 $incompatible_text =
1979 'all' === $feature_id
1980 ? __( 'Incompatible with WooCommerce features', 'woocommerce' )
1981 /* translators: %s = name of a WooCommerce feature */
1982 : sprintf( __( "Incompatible with '%s'", 'woocommerce' ), $features[ $feature_id ]['name'] );
1983 $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>";
1984
1985 $all_plugins_count = count( $all_items );
1986 $all_text = __( 'All', 'woocommerce' );
1987 $all_link = "<a href='plugins.php?plugin_status=all'>{$all_text} <span class='count'>({$all_plugins_count})</span></a>";
1988
1989 return array(
1990 'all' => $all_link,
1991 'incompatible_with_feature' => $incompatible_link,
1992 );
1993 }
1994
1995 /**
1996 * Set the feature nonce to be sent from client side.
1997 *
1998 * @param array $settings Component settings.
1999 *
2000 * @return array
2001 *
2002 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
2003 */
2004 public function set_change_feature_enable_nonce( $settings ) {
2005 $settings['_feature_nonce'] = wp_create_nonce( 'change_feature_enable' );
2006 return $settings;
2007 }
2008
2009 /**
2010 * Changes the feature given it's id, a toggle value and nonce as a query param.
2011 *
2012 * `/wp-admin/post.php?product_block_editor=1&_feature_nonce=1234`, 1 for on
2013 * `/wp-admin/post.php?product_block_editor=0&_feature_nonce=1234`, 0 for off
2014 *
2015 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
2016 */
2017 public function change_feature_enable_from_query_params(): void {
2018 if ( ! current_user_can( 'manage_woocommerce' ) ) {
2019 return;
2020 }
2021
2022 $is_feature_nonce_invalid = ( ! isset( $_GET['_feature_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_feature_nonce'] ) ), 'change_feature_enable' ) );
2023
2024 $query_params_to_remove = array( '_feature_nonce' );
2025
2026 foreach ( array_keys( $this->get_feature_definitions() ) as $feature_id ) {
2027 if ( isset( $_GET[ $feature_id ] ) && is_numeric( $_GET[ $feature_id ] ) ) {
2028 $value = absint( $_GET[ $feature_id ] );
2029
2030 if ( $is_feature_nonce_invalid ) {
2031 wp_die( esc_html__( 'Action failed. Please refresh the page and retry.', 'woocommerce' ) );
2032 return;
2033 }
2034
2035 if ( 1 === $value ) {
2036 $this->change_feature_enable( $feature_id, true );
2037 } elseif ( 0 === $value ) {
2038 $this->change_feature_enable( $feature_id, false );
2039 }
2040 $query_params_to_remove[] = $feature_id;
2041 }
2042 }
2043 if ( count( $query_params_to_remove ) > 1 && isset( $_SERVER['REQUEST_URI'] ) ) {
2044 // phpcs:disable WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
2045 wp_safe_redirect( remove_query_arg( $query_params_to_remove, $_SERVER['REQUEST_URI'] ) );
2046 }
2047 }
2048
2049 /**
2050 * Display the email improvements feedback notice to render CES modal in.
2051 *
2052 * @param string $feature_id The feature id.
2053 * @param bool $is_enabled Whether the feature is enabled.
2054 *
2055 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
2056 */
2057 public function display_email_improvements_feedback_notice( $feature_id, $is_enabled ): void {
2058 if ( 'email_improvements' === $feature_id && ! $is_enabled ) {
2059 set_transient( 'wc_settings_email_improvements_reverted', 'yes', 15 );
2060 add_action(
2061 'admin_notices',
2062 function () {
2063 echo '<div id="wc_settings_features_email_feedback_slotfill"></div>';
2064 }
2065 );
2066 }
2067 }
2068
2069 /**
2070 * Check if the email improvements feature is enabled in preview mode in Settings > Emails.
2071 * This is used to force the email improvements feature without affecting shoppers.
2072 *
2073 * @param string $feature_id The feature id.
2074 * @return bool Whether the email improvements feature is enabled in preview mode.
2075 */
2076 private function is_preview_email_improvements_enabled( string $feature_id ): bool {
2077 if ( 'email_improvements' !== $feature_id ) {
2078 return false;
2079 }
2080 /**
2081 * This filter is documented in templates/emails/email-styles.php
2082 *
2083 * @since 9.9.0
2084 * @param bool $is_email_preview Whether the email is being previewed.
2085 */
2086 $is_email_preview = apply_filters( 'woocommerce_is_email_preview', false );
2087 if ( $is_email_preview ) {
2088 return get_transient( EmailPreview::TRANSIENT_PREVIEW_EMAIL_IMPROVEMENTS ) === 'yes';
2089 }
2090 return false;
2091 }
2092 }
2093