PluginProbe ʕ •ᴥ•ʔ
WooCommerce / 11.0.0-beta.1
WooCommerce v11.0.0-beta.1
11.0.0-beta.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 FeaturesController.php 5 days ago
FeaturesController.php
2189 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 add_action( self::FEATURE_ENABLED_CHANGED_ACTION, array( $this, 'flag_abandoned_cart_recovery_enabled_notice' ), 10, 2 );
170 add_action( 'woocommerce_settings_advanced', array( $this, 'maybe_render_abandoned_cart_recovery_enabled_notice' ), 1 );
171 add_filter( 'woocommerce_settings-advanced', array( $this, 'add_point_of_sale_setting_for_rest_api' ), 10, 1 ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores
172 }
173
174 /**
175 * Register a feature.
176 *
177 * This used to be called during the `woocommerce_register_feature_definitions` action hook,
178 * now it's called directly from get_feature_definitions as needed.
179 *
180 * @param string $slug The ID slug of the feature.
181 * @param string $name The name of the feature that will appear on the Features screen and elsewhere.
182 * @param array $args {
183 * Properties that make up the feature definition. Each of these properties can also be set as a
184 * callback function, as long as that function returns the specified type.
185 *
186 * @type string $default_plugin_compatibility The default plugin compatibility for the feature: either 'compatible' or 'incompatible'. Required.
187 * @type array[] $additional_settings An array of definitions for additional settings controls related to
188 * the feature that will display on the Features screen. See the Settings API
189 * for the schema of these props.
190 * @type string $description A brief description of the feature, used as an input label if the feature
191 * setting is a checkbox.
192 * @type bool $disabled True to disable the setting field for this feature on the Features screen,
193 * so it can't be changed.
194 * @type bool $disable_ui Set to true to hide the setting field for this feature on the
195 * Features screen. Defaults to false.
196 * @type bool $enabled_by_default Set to true to have this feature by opt-out instead of opt-in.
197 * Defaults to false.
198 * @type bool $is_experimental Set to true to display this feature under the "Experimental" heading on
199 * the Features screen. Features set to experimental are also omitted from
200 * the features list in some cases. Defaults to true.
201 * @type bool $skip_compatibility_checks Set to true if the feature should not produce warnings about incompatible plugins.
202 * Defaults to false.
203 * @type string $learn_more_url The URL to the learn more page for the feature.
204 * @type string $option_key The key name for the option that enables/disables the feature.
205 * @type int $order The order that the feature will appear in the list on the Features screen.
206 * Higher number = higher in the list. Defaults to 10.
207 * @type array $setting The properties used by the Settings API to render the setting control on
208 * the Features screen. See the Settings API for the schema of these props.
209 * @type string $deprecated_since The WooCommerce version since which this feature is deprecated.
210 * When set, feature_is_enabled() will force feature value to the deprecated_value
211 * instead of reading from the database.
212 * @type bool $deprecated_value The value to return for deprecated features when feature_is_enabled()
213 * is called. Defaults to false.
214 * }
215 *
216 * @return void
217 */
218 public function add_feature_definition( $slug, $name, array $args = array() ) {
219 $defaults = array(
220 'disable_ui' => false,
221 'enabled_by_default' => false,
222 'is_experimental' => true,
223 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
224 'skip_compatibility_checks' => false,
225 'name' => $name,
226 'order' => 10,
227 'learn_more_url' => '',
228 );
229
230 if ( empty( $args['default_plugin_compatibility'] ) ) {
231 wc_doing_it_wrong(
232 __FUNCTION__,
233 sprintf(
234 'Assuming positive compatibility by default will be deprecated in the future. Please set \'default_plugin_compatibility\' for feature "%s".',
235 esc_html( $slug )
236 ),
237 '10.3.0'
238 );
239 }
240
241 $args = wp_parse_args( $args, $defaults );
242
243 // Sanitize 'default_plugin_compatibility'.
244 if ( ! in_array( $args['default_plugin_compatibility'], FeaturePluginCompatibility::VALID_REGISTRATION_VALUES, true ) ) {
245 $args['default_plugin_compatibility'] = wc_string_to_bool( $args['default_plugin_compatibility'] ) ? FeaturePluginCompatibility::COMPATIBLE : FeaturePluginCompatibility::INCOMPATIBLE;
246 }
247
248 // Support 'is_legacy' flag for backwards compatibility.
249 if ( ! empty( $args['is_legacy'] ) ) {
250 $args['skip_compatibility_checks'] = true;
251 }
252
253 $this->features[ $slug ] = $args;
254 }
255
256 /**
257 * Generate and cache the feature definitions.
258 *
259 * @return array[]
260 */
261 private function get_feature_definitions() {
262 if ( empty( $this->features ) ) {
263 $this->init_feature_definitions();
264 }
265
266 if ( ! $this->registered_additional_features_via_class_calls ) {
267 // This needs to be set to true *before* additional feature definition calls are made,
268 // to prevent infinite loops in case one of these calls ends up calling here again.
269 $this->registered_additional_features_via_class_calls = true;
270
271 // Additional feature definitions.
272 // These used to be tied to the now deprecated woocommerce_register_feature_definitions action,
273 // and aren't processed in init_feature_definitions to avoid circular calls in the dependency injection container.
274 $container = wc_get_container();
275 $container->get( CustomOrdersTableController::class )->add_feature_definition( $this );
276 $container->get( CostOfGoodsSoldController::class )->add_feature_definition( $this );
277
278 $this->init_compatibility_info_by_feature();
279 }
280
281 return $this->features;
282 }
283
284 /**
285 * Initialize the hardcoded feature definitions array.
286 * This doesn't include:
287 * - Features that get initialized via the (deprecated) woocommerce_register_feature_definitions.
288 * - Features whose definition comes from another class. These are initialized directly in get_feature_definitions
289 * to avoid circular calls in the dependency injection container.
290 */
291 private function init_feature_definitions(): void {
292 $alpha_feature_testing_is_enabled = Constants::is_true( 'WOOCOMMERCE_ENABLE_ALPHA_FEATURE_TESTING' );
293 $tracking_enabled = WC_Site_Tracking::is_tracking_enabled();
294
295 $legacy_features = array(
296 'analytics' => array(
297 'name' => __( 'WooCommerce Analytics', 'woocommerce' ),
298 'description' => __( 'Enable WooCommerce Analytics to track your store\'s key metrics and view them in a detailed dashboard. All data stays within your store.', 'woocommerce' ),
299 'option_key' => Analytics::TOGGLE_OPTION_NAME,
300 'is_experimental' => false,
301 'enabled_by_default' => true,
302 'disable_ui' => false,
303 'skip_compatibility_checks' => true,
304 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
305 ),
306 'cart_checkout_blocks' => array(
307 'name' => __( 'Cart & Checkout Blocks', 'woocommerce' ),
308 'description' => __( 'Optimize for faster checkout', 'woocommerce' ),
309 'is_experimental' => false,
310 'disable_ui' => true,
311 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
312 ),
313 'rate_limit_checkout' => array(
314 'name' => __( 'Rate limit Checkout', 'woocommerce' ),
315 'description' => sprintf(
316 // translators: %s is the URL to the rate limiting documentation.
317 __( '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' ),
318 'https://developer.woocommerce.com/docs/apis/store-api/rate-limiting/'
319 ),
320 'is_experimental' => false,
321 'disable_ui' => false,
322 'enabled_by_default' => false,
323 'skip_compatibility_checks' => true,
324 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
325 ),
326 'marketplace' => array(
327 'name' => __( 'Marketplace', 'woocommerce' ),
328 'description' => __(
329 'New, faster way to find extensions and themes for your WooCommerce store',
330 'woocommerce'
331 ),
332 'is_experimental' => false,
333 'enabled_by_default' => true,
334 'disable_ui' => true,
335 'skip_compatibility_checks' => true,
336 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
337 'deprecated_since' => '10.5.0',
338 'deprecated_value' => true,
339 ),
340 // Marked as a legacy feature to avoid compatibility checks, which aren't really relevant to this feature.
341 // https://github.com/woocommerce/woocommerce/pull/39701#discussion_r1376976959.
342 'order_attribution' => array(
343 'name' => __( 'Order Attribution', 'woocommerce' ),
344 'description' => __(
345 'Enable this feature to track and credit channels and campaigns that contribute to orders on your site',
346 'woocommerce'
347 ),
348 'enabled_by_default' => true,
349 'disable_ui' => false,
350 'skip_compatibility_checks' => true,
351 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
352 'is_experimental' => false,
353 ),
354 'site_visibility_badge' => array(
355 'name' => __( 'Site visibility badge', 'woocommerce' ),
356 'description' => __(
357 'Enable the site visibility badge in the WordPress admin bar',
358 'woocommerce'
359 ),
360 'enabled_by_default' => true,
361 'disable_ui' => true,
362 'skip_compatibility_checks' => true,
363 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
364 'is_experimental' => false,
365 'disabled' => false,
366 ),
367 'hpos_fts_indexes' => array(
368 'name' => __( 'HPOS Full text search indexes', 'woocommerce' ),
369 'description' => __(
370 'Create and use full text search indexes for orders. This feature only works with high-performance order storage.',
371 'woocommerce'
372 ),
373 'is_experimental' => true,
374 'enabled_by_default' => false,
375 'skip_compatibility_checks' => true,
376 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
377 'option_key' => CustomOrdersTableController::HPOS_FTS_INDEX_OPTION,
378 ),
379 'hpos_datastore_caching' => array(
380 'name' => __( 'HPOS Data Caching', 'woocommerce' ),
381 'description' => __(
382 'Enable order data caching in the datastore. This feature only works with high-performance order storage and is recommended for stores using object caching.',
383 'woocommerce'
384 ),
385 'is_experimental' => false,
386 'enabled_by_default' => false,
387 'skip_compatibility_checks' => true,
388 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
389 'disable_ui' => false,
390 'option_key' => CustomOrdersTableController::HPOS_DATASTORE_CACHING_ENABLED_OPTION,
391 ),
392 'remote_logging' => array(
393 'name' => __( 'Remote Logging', 'woocommerce' ),
394 'description' => sprintf(
395 /* translators: %1$s: opening link tag, %2$s: closing link tag */
396 __( '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' ),
397 '<a href="' . admin_url( 'admin.php?page=wc-settings&tab=advanced&section=woocommerce_com' ) . '">',
398 '</a>'
399 ),
400 'enabled_by_default' => true,
401 'disable_ui' => false,
402
403 /*
404 * This is not truly a legacy feature (it is not a feature that pre-dates the FeaturesController),
405 * but we wish to handle compatibility checking in a similar fashion to legacy features. The
406 * rational for setting legacy to true is therefore similar to that of the 'order_attribution'
407 * feature.
408 *
409 * @see https://github.com/woocommerce/woocommerce/pull/39701#discussion_r1376976959
410 */
411 'skip_compatibility_checks' => true,
412 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
413 'is_experimental' => false,
414 'setting' => array(
415 'disabled' => function () use ( $tracking_enabled ) {
416 return ! $tracking_enabled;
417 },
418 'desc_tip' => function () use ( $tracking_enabled ) {
419 if ( ! $tracking_enabled ) {
420 return __( '⚠ Usage tracking must be enabled to use remote logging.', 'woocommerce' );
421 }
422
423 return '';
424 },
425 ),
426 ),
427 'deferred_transactional_emails' => array(
428 'name' => __( 'Deferred emails', 'woocommerce' ),
429 'description' => __(
430 'Send transactional emails asynchronously via Action Scheduler instead of during the current request.',
431 'woocommerce'
432 ),
433 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
434 'enabled_by_default' => false,
435 'is_experimental' => false,
436 ),
437 'customer_review_request' => array(
438 'name' => __( 'Customer review request (beta)', 'woocommerce' ),
439 'description' => __(
440 '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.',
441 'woocommerce'
442 ),
443 // Skip compatibility checks like the other opt-in transactional-email features.
444 'skip_compatibility_checks' => true,
445 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
446 'enabled_by_default' => false,
447 'is_experimental' => false,
448 ),
449 'abandoned_cart_recovery' => array(
450 'name' => __( 'Abandoned cart recovery', 'woocommerce' ),
451 'description' => __(
452 'Send a reminder email to shoppers who didn\'t finish checking out.',
453 'woocommerce'
454 ),
455 'skip_compatibility_checks' => false,
456 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
457 'enabled_by_default' => false,
458 'is_experimental' => true,
459 ),
460 'email_improvements' => array(
461 'name' => __( 'Email improvements', 'woocommerce' ),
462 'description' => __(
463 'Enable modern email design for transactional emails',
464 'woocommerce'
465 ),
466
467 /*
468 * This is not truly a legacy feature (it is not a feature that pre-dates the FeaturesController),
469 * but as this feature doesn't affect all extensions, and the rollout is fairly short,
470 * we'll skip the compatibility check by marking this as legacy. This is a workaround until
471 * we can implement a more sophisticated compatibility checking system.
472 *
473 * @see https://github.com/woocommerce/woocommerce/issues/39147
474 * @see https://github.com/woocommerce/woocommerce/issues/55540
475 */
476 'skip_compatibility_checks' => true,
477 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
478 'is_experimental' => false,
479 ),
480 'blueprint' => array(
481 'name' => __( 'Blueprint (beta)', 'woocommerce' ),
482 'description' => __(
483 'Enable blueprint to import and export settings in bulk',
484 'woocommerce'
485 ),
486 'enabled_by_default' => true,
487 'disable_ui' => false,
488
489 /*
490 * This is not truly a legacy feature (it is not a feature that pre-dates the FeaturesController),
491 * but we wish to handle compatibility checking in a similar fashion to legacy features. The
492 * rational for setting legacy to true is therefore similar to that of the 'order_attribution'
493 * feature.
494 *
495 * @see https://github.com/woocommerce/woocommerce/pull/39701#discussion_r1376976959
496 */
497 'skip_compatibility_checks' => true,
498 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
499 'is_experimental' => false,
500 ),
501 'block_email_editor' => array(
502 'name' => __( 'Block Email Editor (alpha)', 'woocommerce' ),
503 'description' => __(
504 'Enable the block-based email editor for transactional emails.',
505 'woocommerce'
506 ),
507 'learn_more_url' => 'https://github.com/woocommerce/woocommerce/discussions/52897#discussioncomment-11630256',
508
509 /*
510 * This is not truly a legacy feature (it is not a feature that pre-dates the FeaturesController),
511 * but we wish to handle compatibility checking in a similar fashion to legacy features. The
512 * rational for setting legacy to true is therefore similar to that of the 'order_attribution'
513 * feature.
514 *
515 * @see https://github.com/woocommerce/woocommerce/pull/39701#discussion_r1376976959
516 */
517 'skip_compatibility_checks' => true,
518 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
519 'enabled_by_default' => false,
520 ),
521 \Automattic\WooCommerce\Internal\VariationGallery\Package::FEATURE_ID => array(
522 'name' => __( 'Variation gallery', 'woocommerce' ),
523 'description' => __(
524 'Add multiple images per product variation. Once enabled, the Additional Variation Images extension will be deactivated and its data migrated.',
525 'woocommerce'
526 ),
527 'option_key' => \Automattic\WooCommerce\Internal\VariationGallery\Package::ENABLE_OPTION_NAME,
528 'is_experimental' => true,
529 'enabled_by_default' => \Automattic\WooCommerce\Internal\VariationGallery\Package::is_in_canary_cohort(),
530 'skip_compatibility_checks' => true,
531 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
532 ),
533 'wc-visual-attribute' => array(
534 'name' => __( 'Color swatches for attributes', 'woocommerce' ),
535 'description' => __(
536 'Add color swatches to product attribute values.',
537 'woocommerce'
538 ),
539 'option_key' => 'woocommerce_feature_wc_visual_attribute_enabled',
540 'is_experimental' => true,
541 'enabled_by_default' => false,
542 'disable_ui' => false,
543 'skip_compatibility_checks' => true,
544 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
545 ),
546 'point_of_sale' => array(
547 'name' => __( 'Point of Sale', 'woocommerce' ),
548 'description' => __(
549 'Enable Point of Sale functionality in the WooCommerce mobile apps.',
550 'woocommerce'
551 ),
552 'is_experimental' => false,
553 'enabled_by_default' => true,
554 'disable_ui' => true,
555 'skip_compatibility_checks' => true,
556 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
557 'deprecated_since' => '11.0.0',
558 'deprecated_value' => true,
559 ),
560 'point_of_sale_staff' => array(
561 'name' => __( 'POS staff', 'woocommerce' ),
562 'description' => __(
563 'Experimental: POS staff management, roles, and order attribution.',
564 'woocommerce'
565 ),
566 'enabled_by_default' => false,
567 // Hidden while incomplete so it can't ship merchant-toggleable; flip to
568 // false when it's ready for an experimental preview.
569 'disable_ui' => true,
570 'is_experimental' => true,
571 'skip_compatibility_checks' => true,
572 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
573 ),
574 'fulfillments' => array(
575 'name' => __( 'Order Fulfillments', 'woocommerce' ),
576 'description' => __(
577 'Enable the Order Fulfillments feature to manage order fulfillment and shipping.',
578 'woocommerce'
579 ),
580 'enabled_by_default' => false,
581 'disable_ui' => true,
582 'is_experimental' => false,
583 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
584 ),
585 'mcp_integration' => array(
586 'name' => __( 'WooCommerce MCP', 'woocommerce' ),
587 'description' => $this->get_mcp_integration_description(),
588 'enabled_by_default' => false,
589 'disable_ui' => false,
590 'is_experimental' => true,
591 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
592 'is_legacy' => false,
593 ),
594 'destroy-empty-sessions' => array(
595 'name' => __( 'Clear Customer Sessions When Empty', 'woocommerce' ),
596 'description' => __(
597 '[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.',
598 'woocommerce'
599 ),
600 'enabled_by_default' => false,
601 'is_experimental' => true,
602 'disable_ui' => false,
603 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
604 ),
605 'agentic_checkout' => array(
606 'name' => __( 'Agentic Checkout API', 'woocommerce' ),
607 'description' => __(
608 '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.',
609 'woocommerce'
610 ),
611 'enabled_by_default' => false,
612 'is_experimental' => true,
613 'disable_ui' => true,
614 'skip_compatibility_checks' => true,
615 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
616 ),
617 'dual_code_graphql_api' => array(
618 'name' => __( 'Dual Code & GraphQL API', 'woocommerce' ),
619 'description' => __(
620 'Experimental code-first API for WooCommerce with automatic GraphQL endpoint generation. Requires PHP 8.1 or later.',
621 'woocommerce'
622 ),
623 'enabled_by_default' => false,
624 'is_experimental' => true,
625 'disable_ui' => true,
626 'skip_compatibility_checks' => true,
627 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
628 ),
629 PushNotifications::FEATURE_NAME => array(
630 'name' => __( 'Push Notifications', 'woocommerce' ),
631 'description' => __(
632 'Enable push notifications for the WooCommerce mobile apps to receive order notifications and store updates.',
633 'woocommerce'
634 ),
635 'is_experimental' => false,
636 'enabled_by_default' => true,
637 'disable_ui' => true,
638 'skip_compatibility_checks' => true,
639 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
640 'deprecated_since' => '10.9.2',
641 'deprecated_value' => true,
642 ),
643 'rest_api_caching' => array(
644 'name' => __( 'REST API Caching', 'woocommerce' ),
645 'description' => sprintf(
646 /* translators: %1$s and %2$s are opening and closing <a> tags */
647 __( 'Enable backend caching and cache control headers for REST API responses via the <code>RestApiCache</code> trait. ⚙️ %1$sConfiguration%2$s', 'woocommerce' ),
648 '<a href="' . admin_url( 'admin.php?page=wc-settings&tab=advanced&section=rest_api_caching' ) . '">',
649 '</a>'
650 ),
651 'enabled_by_default' => false,
652 'is_experimental' => true,
653 'disable_ui' => false,
654 'skip_compatibility_checks' => true,
655 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
656 ),
657 'cart_save_for_later' => array(
658 'name' => __( 'Save for Later in Cart', 'woocommerce' ),
659 'description' => __(
660 'Let shoppers save cart items to a list to purchase later.',
661 'woocommerce'
662 ),
663 'is_experimental' => true,
664 'enabled_by_default' => false,
665 // Custom option_key as we expect this setting to move out of features to
666 // a cart/checkout settings section.
667 'option_key' => 'woocommerce_cart_save_for_later_enabled',
668 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
669 ),
670 'product_wishlist' => array(
671 'name' => __( 'Wishlists', 'woocommerce' ),
672 'description' => __(
673 'Let shoppers save products to a wishlist from product pages. Requires the Add to Cart + Options block on the single-product template.',
674 'woocommerce'
675 ),
676 'is_experimental' => true,
677 'enabled_by_default' => false,
678 'option_key' => 'woocommerce_product_wishlist_enabled',
679 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
680 ),
681 ProductCacheController::FEATURE_NAME => array(
682 'name' => __( 'Cache Product Objects', 'woocommerce' ),
683 'description' => __(
684 '[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.',
685 'woocommerce'
686 ),
687 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
688 'enabled_by_default' => false,
689 'is_experimental' => true,
690 'disable_ui' => false,
691 ),
692 );
693
694 if ( ! $tracking_enabled ) {
695 // Uncheck the remote logging feature when usage tracking is disabled.
696 $legacy_features['remote_logging']['setting']['value'] = 'no';
697 }
698
699 foreach ( $legacy_features as $slug => $definition ) {
700 $this->add_feature_definition( $slug, $definition['name'], $definition );
701 }
702
703 // Preload option caches to minimize future queries for options that do not yet exist or are not set to autoload.
704 wp_prime_option_caches(
705 array_map(
706 static fn( $slug, $definition ) => $definition['option_key'] ?? sprintf( 'woocommerce_feature_%s_enabled', $slug ),
707 array_keys( $this->features ),
708 $this->features
709 )
710 );
711
712 $this->init_compatibility_info_by_feature();
713 }
714
715 /**
716 * Initialize the compatibility_info_by_feature property after all the features have been added.
717 */
718 private function init_compatibility_info_by_feature() {
719 foreach ( array_keys( $this->features ) as $feature_id ) {
720 if ( ! isset( $this->compatibility_info_by_feature[ $feature_id ] ) ) {
721 $this->compatibility_info_by_feature[ $feature_id ] = array(
722 FeaturePluginCompatibility::COMPATIBLE => array(),
723 FeaturePluginCompatibility::INCOMPATIBLE => array(),
724 );
725 }
726 }
727 }
728
729 /**
730 * Generate the description for the MCP integration feature.
731 *
732 * @return string The feature description with conditional permalink warning and documentation link.
733 */
734 private function get_mcp_integration_description() {
735 $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' );
736
737 // Check permalink structure requirement.
738 $permalink_structure = get_option( 'permalink_structure' );
739 if ( empty( $permalink_structure ) ) {
740 $permalinks_url = admin_url( 'options-permalink.php' );
741 $permalink_warning = sprintf(
742 '<br><br><strong>%s:</strong> %s <a href="%s">%s</a>',
743 __( 'Configuration Required', 'woocommerce' ),
744 __( 'WordPress permalinks must be set to anything other than "Plain" for MCP to work.', 'woocommerce' ),
745 $permalinks_url,
746 __( 'Configure Permalinks', 'woocommerce' )
747 );
748 // Add documentation link to permalink warning.
749 $documentation_link = sprintf(
750 ' <a href="%s" target="_blank">%s</a>',
751 'https://github.com/woocommerce/woocommerce/blob/trunk/docs/features/mcp/README.md',
752 __( 'Learn more', 'woocommerce' )
753 );
754 return $base_description . $permalink_warning . $documentation_link;
755 }
756
757 // Add documentation link.
758 $documentation_link = sprintf(
759 ' <a href="%s" target="_blank">%s</a>',
760 'https://github.com/woocommerce/woocommerce/blob/trunk/docs/features/mcp/README.md',
761 __( 'Learn more', 'woocommerce' )
762 );
763
764 return $base_description . $documentation_link;
765 }
766
767 /**
768 * Function to trigger the (now deprecated) 'woocommerce_register_feature_definitions' hook.
769 *
770 * This function must execute immediately before the 'before_woocommerce_init'
771 * action is fired, so that feature compatibility declarations happening
772 * in that action find all the features properly declared already.
773 *
774 * @internal
775 */
776 public function register_additional_features() {
777 if ( $this->registered_additional_features_via_action ) {
778 return;
779 }
780
781 if ( empty( $this->features ) ) {
782 $this->init_feature_definitions();
783 }
784
785 /**
786 * The action for registering features.
787 *
788 * @since 8.3.0
789 *
790 * @param FeaturesController $features_controller The instance of FeaturesController.
791 *
792 * @deprecated 9.9.0 Features should be defined directly in get_feature_definitions.
793 */
794 do_action( 'woocommerce_register_feature_definitions', $this );
795
796 $this->init_compatibility_info_by_feature();
797
798 $this->registered_additional_features_via_action = true;
799 }
800
801 /**
802 * Initialize the class instance.
803 *
804 * @internal
805 *
806 * @param LegacyProxy $proxy The instance of LegacyProxy to use.
807 * @param PluginUtil $plugin_util The instance of PluginUtil to use.
808 */
809 final public function init( LegacyProxy $proxy, PluginUtil $plugin_util ) {
810 $this->proxy = $proxy;
811 $this->plugin_util = $plugin_util;
812
813 $this->plugins_excluded_from_compatibility_ui = $plugin_util->get_plugins_excluded_from_compatibility_ui();
814 }
815
816 /**
817 * Get all the existing WooCommerce features.
818 *
819 * Returns an associative array where keys are unique feature ids
820 * and values are arrays with these keys:
821 *
822 * - name (string)
823 * - description (string)
824 * - is_experimental (bool)
825 * - is_enabled (bool) (only if $include_enabled_info is passed as true)
826 *
827 * @param bool $include_experimental Include also experimental/work in progress features in the list.
828 * @param bool $include_enabled_info True to include the 'is_enabled' field in the returned features info.
829 * @returns array An array of information about existing features.
830 */
831 public function get_features( bool $include_experimental = false, bool $include_enabled_info = false ): array {
832 $features = $this->get_feature_definitions();
833
834 if ( ! $include_experimental ) {
835 $features = array_filter(
836 $features,
837 function ( $feature ) {
838 return ! $feature['is_experimental'];
839 }
840 );
841 }
842
843 if ( $include_enabled_info ) {
844 foreach ( array_keys( $features ) as $feature_id ) {
845 $is_enabled = false;
846 // For deprecated features, use the deprecated_value directly without triggering the deprecation notice.
847 // The deprecation notice should only fire for external code checking feature status, not for internal listing.
848 if ( ! empty( $features[ $feature_id ]['deprecated_since'] ) ) {
849 $is_enabled = (bool) ( $features[ $feature_id ]['deprecated_value'] ?? false );
850 } else {
851 $is_enabled = $this->feature_is_enabled( $feature_id );
852 }
853 $features[ $feature_id ]['is_enabled'] = $is_enabled;
854 }
855 }
856
857 if ( isset( $features['wc-visual-attribute'] ) && ! wp_is_block_theme() ) {
858 $features['wc-visual-attribute']['disable_ui'] = true;
859 }
860
861 return $features;
862 }
863
864 /**
865 * Get the default plugin compatibility for a given feature.
866 *
867 * @param string $feature_id Feature id to check.
868 * @return string Either 'compatible' or 'incompatible'.
869 * @throws \InvalidArgumentException If the feature doesn't exist.
870 */
871 public function get_default_plugin_compatibility( string $feature_id ): string {
872 $feature = $this->get_feature_definition( $feature_id );
873 if ( null === $feature ) {
874 throw new \InvalidArgumentException( esc_html( "The WooCommerce feature '$feature_id' doesn't exist" ) );
875 }
876
877 $default_plugin_compatibility = $feature['default_plugin_compatibility'] ?? FeaturePluginCompatibility::COMPATIBLE;
878
879 // Filter below is only fired for backwards compatibility with (now removed) get_plugins_are_incompatible_by_default().
880 /**
881 * Filter to determine if plugins that don't declare compatibility nor incompatibility with a given feature
882 * are to be considered incompatible with that feature.
883 *
884 * @param bool $incompatible_by_default Default value, true if plugins are to be considered incompatible by default with the feature.
885 * @param string $feature_id The feature to check.
886 *
887 * @since 9.2.0
888 */
889 $incompatible_by_default = (bool) apply_filters( 'woocommerce_plugins_are_incompatible_with_feature_by_default', FeaturePluginCompatibility::INCOMPATIBLE === $default_plugin_compatibility, $feature_id );
890
891 return $incompatible_by_default ? FeaturePluginCompatibility::INCOMPATIBLE : FeaturePluginCompatibility::COMPATIBLE;
892 }
893
894 /**
895 * Get the definition array for a specific feature.
896 *
897 * @param string $feature_id Unique feature id.
898 * @return array|null The feature definition array, or null if the feature doesn't exist.
899 *
900 * @since 10.5.0
901 */
902 public function get_feature_definition( string $feature_id ): ?array {
903 return $this->get_feature_definitions()[ $feature_id ] ?? null;
904 }
905
906 /**
907 * Check if a given feature is currently enabled.
908 *
909 * Note: This method does not log deprecation notices for deprecated features.
910 * Deprecation logging is handled by FeaturesUtil::feature_is_enabled() which is the public API.
911 *
912 * @param string $feature_id Unique feature id.
913 * @return bool True if the feature is enabled, false if not or if the feature doesn't exist.
914 */
915 public function feature_is_enabled( string $feature_id ): bool {
916 $feature = $this->get_feature_definition( $feature_id );
917
918 if ( null === $feature ) {
919 return false;
920 }
921
922 // Handle deprecated features - return the backwards-compatible value.
923 if ( ! empty( $feature['deprecated_since'] ) ) {
924 return (bool) ( $feature['deprecated_value'] ?? false );
925 }
926
927 if ( $this->is_preview_email_improvements_enabled( $feature_id ) ) {
928 return true;
929 }
930
931 $default_value = $this->feature_is_enabled_by_default( $feature_id ) ? 'yes' : 'no';
932 $value = 'yes' === get_option( $this->feature_enable_option_name( $feature_id ), $default_value );
933 return $value;
934 }
935
936 /**
937 * Check if a given feature is enabled by default.
938 *
939 * @param string $feature_id Unique feature id.
940 * @return boolean TRUE if the feature is enabled by default, FALSE otherwise.
941 */
942 private function feature_is_enabled_by_default( string $feature_id ): bool {
943 $features = $this->get_feature_definitions();
944
945 return ! empty( $features[ $feature_id ]['enabled_by_default'] );
946 }
947
948 /**
949 * Change the enabled/disabled status of a feature.
950 *
951 * @param string $feature_id Unique feature id.
952 * @param bool $enable True to enable the feature, false to disable it.
953 * @return bool True on success, false if feature doesn't exist or the new value is the same as the old value.
954 */
955 public function change_feature_enable( string $feature_id, bool $enable ): bool {
956 if ( ! $this->feature_exists( $feature_id ) ) {
957 return false;
958 }
959
960 return update_option( $this->feature_enable_option_name( $feature_id ), $enable ? 'yes' : 'no', 'on' );
961 }
962
963 /**
964 * Declare (in)compatibility with a given feature for a given plugin.
965 *
966 * This method MUST be executed from inside a handler for the 'before_woocommerce_init' hook.
967 *
968 * The plugin name is expected to be in the form 'directory/file.php' and be one of the keys
969 * of the array returned by 'get_plugins', but this won't be checked. Plugins are expected to use
970 * FeaturesUtil::declare_compatibility instead, passing the full plugin file path instead of the plugin name.
971 *
972 * @param string $feature_id Unique feature id.
973 * @param string $plugin_file Plugin file path, either full or in the form 'directory/file.php'.
974 * @param bool $positive_compatibility True if the plugin declares being compatible with the feature, false if it declares being incompatible.
975 * @return bool True on success, false on error (feature doesn't exist or not inside the required hook).
976 * @throws \Exception A plugin attempted to declare itself as compatible and incompatible with a given feature at the same time.
977 */
978 public function declare_compatibility( string $feature_id, string $plugin_file, bool $positive_compatibility = true ): bool {
979 if ( ! $this->proxy->call_function( 'doing_action', 'before_woocommerce_init' ) ) {
980 $class_and_method = ( new \ReflectionClass( $this ) )->getShortName() . '::' . __FUNCTION__;
981 /* translators: 1: class::method 2: before_woocommerce_init */
982 $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' );
983 return false;
984 }
985 if ( ! $this->feature_exists( $feature_id ) ) {
986 return false;
987 }
988
989 if ( $this->lazy ) {
990 // Lazy mode: Queue to be normalized later.
991 $this->pending_declarations[] = array( $feature_id, $plugin_file, $positive_compatibility );
992 return true;
993 }
994
995 // Late call: Normalize and register immediately.
996 return $this->register_compatibility_internal( $feature_id, $plugin_file, $positive_compatibility );
997 }
998
999 /**
1000 * Registers compatibility information internally for a given feature and plugin file.
1001 *
1002 * This method normalizes the plugin file path to a plugin ID, handles validation and logging for invalid plugins,
1003 * and registers the compatibility data if valid.
1004 * It updates the internal compatibility arrays, checks for conflicts (e.g., a plugin declaring both
1005 * compatible and incompatible with the same feature), and throws an exception if a conflict is detected.
1006 * Duplicate declarations (same compatibility type) are ignored.
1007 *
1008 * This is an internal helper method and should not be called directly.
1009 *
1010 * @internal For usage by WooCommerce core only. Backwards compatibility not guaranteed.
1011 * @since 10.1.0
1012 *
1013 * @param string $feature_id Unique feature ID.
1014 * @param string $plugin_file Raw plugin file path (full or 'directory/file.php').
1015 * @param bool $positive_compatibility True if declaring compatibility, false if declaring incompatibility.
1016 * @return bool True on successful registration, false if the feature does not exist.
1017 * @throws \Exception If the plugin attempts to declare both compatibility and incompatibility for the same feature.
1018 */
1019 private function register_compatibility_internal( string $feature_id, string $plugin_file, bool $positive_compatibility ): bool {
1020 if ( ! $this->feature_exists( $feature_id ) ) {
1021 return false;
1022 }
1023
1024 // Normalize and validate plugin file.
1025 $plugin_id = $this->plugin_util->get_wp_plugin_id( $plugin_file );
1026 if ( ! $plugin_id ) {
1027 $logger = $this->proxy->call_function( 'wc_get_logger' );
1028 $logger->error( "FeaturesController: Invalid plugin file '{$plugin_file}' for feature '{$feature_id}'." );
1029 return false;
1030 }
1031
1032 // Register compatibility by plugin.
1033 ArrayUtil::ensure_key_is_array( $this->compatibility_info_by_plugin, $plugin_id );
1034
1035 $key = $positive_compatibility ? FeaturePluginCompatibility::COMPATIBLE : FeaturePluginCompatibility::INCOMPATIBLE;
1036 $opposite_key = $positive_compatibility ? FeaturePluginCompatibility::INCOMPATIBLE : FeaturePluginCompatibility::COMPATIBLE;
1037 ArrayUtil::ensure_key_is_array( $this->compatibility_info_by_plugin[ $plugin_id ], $key );
1038 ArrayUtil::ensure_key_is_array( $this->compatibility_info_by_plugin[ $plugin_id ], $opposite_key );
1039
1040 if ( in_array( $feature_id, $this->compatibility_info_by_plugin[ $plugin_id ][ $opposite_key ], true ) ) {
1041 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" ) );
1042 }
1043
1044 if ( ! in_array( $feature_id, $this->compatibility_info_by_plugin[ $plugin_id ][ $key ], true ) ) {
1045 $this->compatibility_info_by_plugin[ $plugin_id ][ $key ][] = $feature_id;
1046 }
1047
1048 // Register compatibility by feature.
1049 $key = $positive_compatibility ? FeaturePluginCompatibility::COMPATIBLE : FeaturePluginCompatibility::INCOMPATIBLE;
1050
1051 if ( ! in_array( $plugin_id, $this->compatibility_info_by_feature[ $feature_id ][ $key ], true ) ) {
1052 $this->compatibility_info_by_feature[ $feature_id ][ $key ][] = $plugin_id;
1053 }
1054
1055 return true;
1056 }
1057
1058 /**
1059 * Processes any pending compatibility declarations by normalizing plugin file paths
1060 * and registering them internally.
1061 *
1062 * This method is called lazily when compatibility information is queried (via
1063 * get_compatible_features_for_plugin() or get_compatible_plugins_for_feature()).
1064 * It resolves plugin IDs using PluginUtil and logs errors for unrecognized plugins.
1065 * Pending declarations are cleared after processing to avoid redundant work.
1066 *
1067 * @internal For usage by WooCommerce core only. Backwards compatibility not guaranteed.
1068 * @since 10.1.0
1069 * @return void
1070 */
1071 private function process_pending_declarations(): void {
1072 if ( empty( $this->pending_declarations ) ) {
1073 return;
1074 }
1075
1076 foreach ( $this->pending_declarations as $declaration ) {
1077 list( $feature_id, $plugin_file, $positive_compatibility ) = $declaration;
1078
1079 // Register internally.
1080 $this->register_compatibility_internal( $feature_id, $plugin_file, $positive_compatibility );
1081 }
1082
1083 $this->pending_declarations = array();
1084 $this->lazy = false;
1085 }
1086
1087 /**
1088 * Check whether a feature exists with a given id.
1089 *
1090 * @param string $feature_id The feature id to check.
1091 * @return bool True if the feature exists.
1092 */
1093 private function feature_exists( string $feature_id ): bool {
1094 $features = $this->get_feature_definitions();
1095
1096 return isset( $features[ $feature_id ] );
1097 }
1098
1099 /**
1100 * Get the ids of the features that a certain plugin has declared compatibility for.
1101 *
1102 * This method can't be called before the 'woocommerce_init' hook is fired.
1103 *
1104 * @param string $plugin_name Plugin name, in the form 'directory/file.php'.
1105 * @param bool $enabled_features_only True to return only names of enabled plugins.
1106 * @param bool $resolve_uncertain True to resolve the uncertain features to compatible or incompatible.
1107 * @return array An array having a 'compatible' and an 'incompatible' key, each holding an array of feature ids.
1108 */
1109 public function get_compatible_features_for_plugin( string $plugin_name, bool $enabled_features_only = false, bool $resolve_uncertain = false ): array {
1110 $this->process_pending_declarations();
1111 $this->verify_did_woocommerce_init( __FUNCTION__ );
1112
1113 $features = $this->get_feature_definitions();
1114
1115 if ( $enabled_features_only ) {
1116 $features = array_filter(
1117 $features,
1118 array( $this, 'feature_is_enabled' ),
1119 ARRAY_FILTER_USE_KEY
1120 );
1121 }
1122
1123 if ( ! isset( $this->compatibility_info_by_plugin[ $plugin_name ] ) ) {
1124 return array(
1125 FeaturePluginCompatibility::COMPATIBLE => array(),
1126 FeaturePluginCompatibility::INCOMPATIBLE => array(),
1127 FeaturePluginCompatibility::UNCERTAIN => array_keys( $features ),
1128 );
1129 }
1130
1131 $info = $this->compatibility_info_by_plugin[ $plugin_name ];
1132 $info[ FeaturePluginCompatibility::COMPATIBLE ] = array_values( array_intersect( array_keys( $features ), $info[ FeaturePluginCompatibility::COMPATIBLE ] ) );
1133 $info[ FeaturePluginCompatibility::INCOMPATIBLE ] = array_values( array_intersect( array_keys( $features ), $info[ FeaturePluginCompatibility::INCOMPATIBLE ] ) );
1134 $info[ FeaturePluginCompatibility::UNCERTAIN ] = array_values( array_diff( array_keys( $features ), $info[ FeaturePluginCompatibility::COMPATIBLE ], $info[ FeaturePluginCompatibility::INCOMPATIBLE ] ) );
1135
1136 if ( $resolve_uncertain ) {
1137 foreach ( $info[ FeaturePluginCompatibility::UNCERTAIN ] as $feature_id ) {
1138 $key = $this->get_default_plugin_compatibility( $feature_id );
1139 $info[ $key ][] = $feature_id;
1140 }
1141
1142 $info[ FeaturePluginCompatibility::UNCERTAIN ] = array();
1143 }
1144
1145 return $info;
1146 }
1147
1148 /**
1149 * Get the names of the plugins that have been declared compatible or incompatible with a given feature.
1150 *
1151 * @param string $feature_id Feature id.
1152 * @param bool $active_only True to return only active plugins.
1153 * @param bool $resolve_uncertain True to resolve the uncertain plugins to compatible or incompatible.
1154 * @return array An array having a 'compatible', an 'incompatible' and an 'uncertain' key, each holding an array of plugin names.
1155 */
1156 public function get_compatible_plugins_for_feature( string $feature_id, bool $active_only = false, bool $resolve_uncertain = false ): array {
1157 $this->process_pending_declarations();
1158 $this->verify_did_woocommerce_init( __FUNCTION__ );
1159
1160 $woo_aware_plugins = $this->plugin_util->get_woocommerce_aware_plugins( $active_only );
1161 if ( ! $this->feature_exists( $feature_id ) ) {
1162 return array(
1163 FeaturePluginCompatibility::COMPATIBLE => array(),
1164 FeaturePluginCompatibility::INCOMPATIBLE => array(),
1165 FeaturePluginCompatibility::UNCERTAIN => $woo_aware_plugins,
1166 );
1167 }
1168
1169 $info = $this->compatibility_info_by_feature[ $feature_id ];
1170 ArrayUtil::ensure_key_is_array( $info, FeaturePluginCompatibility::UNCERTAIN );
1171
1172 // Resolve uncertain plugin compatibility?
1173 $uncertain_plugins = array_values( array_diff( $woo_aware_plugins, $info[ FeaturePluginCompatibility::COMPATIBLE ], $info[ FeaturePluginCompatibility::INCOMPATIBLE ] ) );
1174 $key = $resolve_uncertain ? $this->get_default_plugin_compatibility( $feature_id ) : FeaturePluginCompatibility::UNCERTAIN;
1175 $info[ $key ] = array_merge( $info[ $key ], $uncertain_plugins );
1176
1177 return $info;
1178 }
1179
1180 /**
1181 * Check if the 'woocommerce_init' has run or is running, do a 'wc_doing_it_wrong' if not.
1182 *
1183 * @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.
1184 *
1185 * @return bool True if 'woocommerce_init' has run or is running, false otherwise.
1186 */
1187 private function verify_did_woocommerce_init( ?string $function_name = null ): bool {
1188 if ( ! $this->proxy->call_function( 'did_action', 'woocommerce_init' ) &&
1189 ! $this->proxy->call_function( 'doing_action', 'woocommerce_init' ) ) {
1190 if ( ! is_null( $function_name ) ) {
1191 $class_and_method = ( new \ReflectionClass( $this ) )->getShortName() . '::' . $function_name;
1192 /* translators: 1: class::method 2: plugins_loaded */
1193 $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' );
1194 }
1195 return false;
1196 }
1197
1198 return true;
1199 }
1200
1201 /**
1202 * Get the name of the option that enables/disables a given feature.
1203 *
1204 * Note that it doesn't check if the feature actually exists. Instead it
1205 * defaults to "woocommerce_feature_{$feature_id}_enabled" if a different
1206 * name isn't specified in the feature registration.
1207 *
1208 * @param string $feature_id The id of the feature.
1209 * @return string The option that enables or disables the feature.
1210 */
1211 public function feature_enable_option_name( string $feature_id ): string {
1212 $features = $this->get_feature_definitions();
1213
1214 if ( ! empty( $features[ $feature_id ]['option_key'] ) ) {
1215 return $features[ $feature_id ]['option_key'];
1216 }
1217
1218 return "woocommerce_feature_{$feature_id}_enabled";
1219 }
1220
1221 /**
1222 * Check if the compatibility checks should be skipped for a given feature.
1223 *
1224 * @since 10.3.0
1225 *
1226 * @param string $feature_id The feature id to check.
1227 * @return bool TRUE if the compatibility checks should be skipped.
1228 */
1229 public function should_skip_compatibility_checks( string $feature_id ): bool {
1230 $features = $this->get_feature_definitions();
1231
1232 return ! empty( $features[ $feature_id ]['skip_compatibility_checks'] );
1233 }
1234
1235 /**
1236 * Sets a flag indicating that it's allowed to enable features for which incompatible plugins are active
1237 * from the WooCommerce feature settings page.
1238 */
1239 public function allow_enabling_features_with_incompatible_plugins(): void {
1240 $this->force_allow_enabling_features = true;
1241 }
1242
1243 /**
1244 * Sets a flag indicating that it's allowed to activate plugins for which incompatible features are enabled
1245 * from the WordPress plugins page.
1246 */
1247 public function allow_activating_plugins_with_incompatible_features(): void {
1248 $this->force_allow_enabling_plugins = true;
1249 }
1250
1251 /**
1252 * Adds our callbacks for the `updated_option` and `added_option` filter hooks.
1253 *
1254 * We delay adding these hooks until `init`, because both callbacks need to load our list of feature definitions,
1255 * and building that list requires translating various strings (which should not be done earlier than `init`).
1256 *
1257 * @return void
1258 *
1259 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1260 */
1261 public function start_listening_for_option_changes(): void {
1262 add_filter( 'updated_option', array( $this, 'process_updated_option' ), 999, 3 );
1263 add_filter( 'added_option', array( $this, 'process_added_option' ), 999, 3 );
1264 }
1265
1266 /**
1267 * Handler for the 'added_option' hook.
1268 *
1269 * It fires FEATURE_ENABLED_CHANGED_ACTION when a feature is enabled or disabled.
1270 *
1271 * @param string $option The option that has been created.
1272 * @param mixed $value The value of the option.
1273 *
1274 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1275 */
1276 public function process_added_option( string $option, $value ) {
1277 $this->process_updated_option( $option, false, $value );
1278 }
1279
1280 /**
1281 * Handler for the 'updated_option' hook.
1282 *
1283 * It fires FEATURE_ENABLED_CHANGED_ACTION when a feature is enabled or disabled.
1284 *
1285 * @param string $option The option that has been modified.
1286 * @param mixed $old_value The old value of the option.
1287 * @param mixed $value The new value of the option.
1288 *
1289 * @return void
1290 *
1291 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1292 */
1293 public function process_updated_option( string $option, $old_value, $value ) {
1294 $matches = array();
1295 $is_default_key = preg_match( '/^woocommerce_feature_([a-zA-Z0-9_]+)_enabled$/', $option, $matches );
1296 $features_with_custom_keys = array_filter(
1297 $this->get_feature_definitions(),
1298 function ( $feature ) {
1299 return ! empty( $feature['option_key'] );
1300 }
1301 );
1302 $custom_keys = wp_list_pluck( $features_with_custom_keys, 'option_key' );
1303
1304 if ( ! $is_default_key && ! in_array( $option, $custom_keys, true ) ) {
1305 return;
1306 }
1307
1308 if ( $value === $old_value ) {
1309 return;
1310 }
1311
1312 $feature_id = '';
1313 if ( $is_default_key ) {
1314 $feature_id = $matches[1];
1315 } elseif ( in_array( $option, $custom_keys, true ) ) {
1316 $feature_id = array_search( $option, $custom_keys, true );
1317 }
1318
1319 if ( ! $feature_id ) {
1320 return;
1321 }
1322
1323 WC_Tracks::record_event(
1324 self::FEATURE_ENABLED_CHANGED_ACTION,
1325 array(
1326 'feature_id' => $feature_id,
1327 'enabled' => $value,
1328 )
1329 );
1330
1331 /**
1332 * Action triggered when a feature is enabled or disabled (the value of the corresponding setting option is changed).
1333 *
1334 * @param string $feature_id The id of the feature.
1335 * @param bool $enabled True if the feature has been enabled, false if it has been disabled.
1336 *
1337 * @since 7.0.0
1338 */
1339 do_action( self::FEATURE_ENABLED_CHANGED_ACTION, $feature_id, 'yes' === $value );
1340 }
1341
1342 /**
1343 * Handler for the 'woocommerce_get_sections_advanced' hook,
1344 * it adds the "Features" section to the advanced settings page.
1345 *
1346 * @param array $sections The original sections array.
1347 * @return array The updated sections array.
1348 *
1349 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1350 */
1351 public function add_features_section( $sections ) {
1352 if ( ! isset( $sections['features'] ) ) {
1353 $sections['features'] = __( 'Features', 'woocommerce' );
1354 }
1355 return $sections;
1356 }
1357
1358 /**
1359 * Handler for the 'woocommerce_settings-advanced' hook, which defines the settings
1360 * exposed in the wc/v3 settings REST API for the 'advanced' group. It appends the
1361 * Point of Sale feature flag setting.
1362 *
1363 * This is a compatibility shim for the WooCommerce mobile apps: app versions released
1364 * before the point_of_sale feature became always enabled (deprecated in 11.0.0) read and
1365 * write this setting via wc/v3/settings/advanced/woocommerce_feature_point_of_sale_enabled
1366 * to decide whether POS can be used. The setting is no longer rendered in the admin UI;
1367 * this shim can be removed once those app versions are no longer supported.
1368 *
1369 * @param array $settings The settings of the 'advanced' group, as exposed in the REST API.
1370 * @return array The updated settings array.
1371 *
1372 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1373 */
1374 public function add_point_of_sale_setting_for_rest_api( $settings ): array {
1375 $settings[] = array(
1376 'id' => 'woocommerce_feature_point_of_sale_enabled',
1377 'option_key' => 'woocommerce_feature_point_of_sale_enabled',
1378 'label' => __( 'Point of Sale', 'woocommerce' ),
1379 'description' => __( 'Enable Point of Sale functionality in the WooCommerce mobile apps.', 'woocommerce' ),
1380 'type' => 'checkbox',
1381 'default' => 'yes',
1382 );
1383 return $settings;
1384 }
1385
1386 /**
1387 * Handler for the 'woocommerce_get_settings_advanced' hook,
1388 * it adds the settings UI for all the existing features.
1389 *
1390 * Note that the settings added via the 'woocommerce_settings_features' hook will be
1391 * displayed in the non-experimental features section.
1392 *
1393 * @param array $settings The existing settings for the corresponding settings section.
1394 * @param string $current_section The section to get the settings for.
1395 * @return array The updated settings array.
1396 *
1397 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1398 */
1399 public function add_feature_settings( $settings, $current_section ): array {
1400 if ( 'features' !== $current_section ) {
1401 return $settings;
1402 }
1403
1404 $feature_settings = array(
1405 array(
1406 'title' => __( 'Features', 'woocommerce' ),
1407 'type' => 'title',
1408 'desc' => __( 'Start using new features that are being progressively rolled out to improve the store management experience.', 'woocommerce' ),
1409 'id' => 'features_options',
1410 ),
1411 );
1412
1413 $features = $this->get_features( true );
1414
1415 $feature_ids = array_keys( $features );
1416 usort(
1417 $feature_ids,
1418 function ( $feature_id_a, $feature_id_b ) use ( $features ) {
1419 return ( $features[ $feature_id_b ]['order'] ?? 0 ) <=> ( $features[ $feature_id_a ]['order'] ?? 0 );
1420 }
1421 );
1422 $experimental_feature_ids = array_filter(
1423 $feature_ids,
1424 function ( $feature_id ) use ( $features ) {
1425 return $features[ $feature_id ]['is_experimental'] ?? false;
1426 }
1427 );
1428 $mature_feature_ids = array_diff( $feature_ids, $experimental_feature_ids );
1429 $feature_ids = array_merge( $mature_feature_ids, array( 'mature_features_end' ), $experimental_feature_ids );
1430
1431 foreach ( $feature_ids as $id ) {
1432 if ( 'mature_features_end' === $id ) {
1433 // phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment
1434 /**
1435 * Filter allowing to add additional settings to the WooCommerce Advanced - Features settings page.
1436 *
1437 * @param bool $disabled False.
1438 */
1439 $feature_settings = apply_filters( 'woocommerce_settings_features', $feature_settings );
1440 // phpcs:enable WooCommerce.Commenting.CommentHooks.MissingSinceComment
1441
1442 if ( ! empty( $experimental_feature_ids ) ) {
1443 $feature_settings[] = array(
1444 'type' => 'sectionend',
1445 'id' => 'features_options',
1446 );
1447
1448 $feature_settings[] = array(
1449 'title' => __( 'Experimental features', 'woocommerce' ),
1450 'type' => 'title',
1451 'desc' => __( 'These features are either experimental or incomplete, enable them at your own risk!', 'woocommerce' ),
1452 'id' => 'experimental_features_options',
1453 );
1454 }
1455 continue;
1456 }
1457
1458 if ( 'new_navigation' === $id && 'yes' !== get_option( $this->feature_enable_option_name( $id ), 'no' ) ) {
1459 continue;
1460 }
1461
1462 if ( isset( $features[ $id ]['disable_ui'] ) && $features[ $id ]['disable_ui'] ) {
1463 continue;
1464 }
1465
1466 $feature_settings[] = $this->get_setting_for_feature( $id, $features[ $id ] );
1467
1468 $additional_settings = $features[ $id ]['additional_settings'] ?? array();
1469 if ( count( $additional_settings ) > 0 ) {
1470 $feature_settings = array_merge( $feature_settings, $additional_settings );
1471 }
1472 }
1473
1474 $feature_settings[] = array(
1475 'type' => 'sectionend',
1476 'id' => empty( $experimental_feature_ids ) ? 'features_options' : 'experimental_features_options',
1477 );
1478
1479 if ( $this->verify_did_woocommerce_init() ) {
1480 // Allow feature setting properties to be determined dynamically just before being rendered.
1481 $feature_settings = array_map(
1482 function ( $feature_setting ) {
1483 foreach ( $feature_setting as $prop => $value ) {
1484 if ( is_callable( $value ) ) {
1485 $feature_setting[ $prop ] = call_user_func( $value );
1486 }
1487 }
1488
1489 return $feature_setting;
1490 },
1491 $feature_settings
1492 );
1493 }
1494
1495 return $feature_settings;
1496 }
1497
1498 /**
1499 * Get the parameters to display the setting enable/disable UI for a given feature.
1500 *
1501 * @param string $feature_id The feature id.
1502 * @param array $feature The feature parameters, as returned by get_features.
1503 * @return array The parameters to add to the settings array.
1504 */
1505 private function get_setting_for_feature( string $feature_id, array $feature ): array {
1506 $description = $feature['description'] ?? '';
1507 $disabled = false;
1508 $desc_tip = '';
1509 $tooltip = $feature['tooltip'] ?? '';
1510 $type = $feature['type'] ?? 'checkbox';
1511 $setting_definition = $feature['setting'] ?? array();
1512
1513 // phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment
1514 /**
1515 * Filter allowing WooCommerce Admin to be disabled.
1516 *
1517 * @param bool $disabled False.
1518 */
1519 $admin_features_disabled = apply_filters( 'woocommerce_admin_disabled', false );
1520 // phpcs:enable WooCommerce.Commenting.CommentHooks.MissingSinceComment
1521
1522 if ( ( 'analytics' === $feature_id || 'new_navigation' === $feature_id ) && $admin_features_disabled ) {
1523 $disabled = true;
1524 $desc_tip = __( 'WooCommerce Admin has been disabled', 'woocommerce' );
1525 } elseif ( 'new_navigation' === $feature_id ) {
1526 $update_text = sprintf(
1527 // translators: 1: line break tag.
1528 __(
1529 '%1$s This navigation will soon become unavailable while we make necessary improvements.
1530 If you turn it off now, you will not be able to turn it back on.',
1531 'woocommerce'
1532 ),
1533 '<br/>'
1534 );
1535
1536 $needs_update = version_compare( get_bloginfo( 'version' ), '5.6', '<' );
1537 if ( $needs_update && current_user_can( 'update_core' ) && current_user_can( 'update_php' ) ) {
1538 $update_text = sprintf(
1539 // translators: 1: line break tag, 2: open link to WordPress update link, 3: close link tag.
1540 __( '%1$s %2$sUpdate WordPress to enable the new navigation%3$s', 'woocommerce' ),
1541 '<br/>',
1542 '<a href="' . self_admin_url( 'update-core.php' ) . '" target="_blank">',
1543 '</a>'
1544 );
1545 $disabled = true;
1546 }
1547
1548 if ( ! empty( $update_text ) ) {
1549 $description .= $update_text;
1550 }
1551 }
1552
1553 if ( ! $this->should_skip_compatibility_checks( $feature_id ) && ! $disabled && $this->verify_did_woocommerce_init() ) {
1554 $plugin_info_for_feature = $this->get_compatible_plugins_for_feature( $feature_id, true );
1555 $desc_tip = $this->plugin_util->generate_incompatible_plugin_feature_warning( $feature_id, $plugin_info_for_feature );
1556 }
1557
1558 /**
1559 * Filter to customize the description tip that appears under the description of each feature in the features settings page.
1560 *
1561 * @since 7.1.0
1562 *
1563 * @param string $desc_tip The original description tip.
1564 * @param string $feature_id The id of the feature for which the description tip is being customized.
1565 * @param bool $disabled True if the UI currently prevents changing the enable/disable status of the feature.
1566 * @return string The new description tip to use.
1567 */
1568 $desc_tip = apply_filters( 'woocommerce_feature_description_tip', $desc_tip, $feature_id, $disabled );
1569
1570 $feature_setting_defaults = array(
1571 'title' => $feature['name'],
1572 'desc' => $description,
1573 'type' => $type,
1574 'id' => $this->feature_enable_option_name( $feature_id ),
1575 'disabled' => $disabled && ! $this->force_allow_enabling_features,
1576 'desc_tip' => $desc_tip,
1577 'tooltip' => $tooltip,
1578 'default' => $this->feature_is_enabled_by_default( $feature_id ) ? 'yes' : 'no',
1579 );
1580
1581 $feature_setting = wp_parse_args( $setting_definition, $feature_setting_defaults );
1582
1583 if ( ! empty( $feature['learn_more_url'] ) ) {
1584 $feature_setting['desc'] .= sprintf(
1585 '<span class="learn-more-link"><a href="%s" target="_blank">%s</a></span>',
1586 esc_attr( $feature['learn_more_url'] ),
1587 esc_html__( 'Learn more', 'woocommerce' )
1588 );
1589 }
1590
1591 /**
1592 * Allows to modify feature setting that will be used to render in the feature page.
1593 *
1594 * @param array $feature_setting The feature setting. Describes the feature:
1595 * - title: The title of the feature.
1596 * - desc: The description of the feature. Will be displayed under the title.
1597 * - 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.
1598 * - id: The id of the feature. Will be used as the name of the setting.
1599 * - disabled: Whether the feature is disabled or not.
1600 * - desc_tip: The description tip of the feature. Will be displayed as a tooltip next to the description.
1601 * - tooltip: The tooltip of the feature. Will be displayed as a tooltip next to the name.
1602 * - default: The default value of the feature.
1603 * @param string $feature_id The id of the feature.
1604 * @since 8.0.0
1605 */
1606 return apply_filters( 'woocommerce_feature_setting', $feature_setting, $feature_id );
1607 }
1608
1609 /**
1610 * Handle the plugin deactivation hook.
1611 *
1612 * @param string $plugin_name Name of the plugin that has been deactivated.
1613 *
1614 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1615 */
1616 public function handle_plugin_deactivation( $plugin_name ): void {
1617 unset( $this->compatibility_info_by_plugin[ $plugin_name ] );
1618
1619 foreach ( array_keys( $this->compatibility_info_by_feature ) as $feature ) {
1620 $compatibles = $this->compatibility_info_by_feature[ $feature ][ FeaturePluginCompatibility::COMPATIBLE ];
1621 $this->compatibility_info_by_feature[ $feature ][ FeaturePluginCompatibility::COMPATIBLE ] = array_diff( $compatibles, array( $plugin_name ) );
1622
1623 $incompatibles = $this->compatibility_info_by_feature[ $feature ][ FeaturePluginCompatibility::INCOMPATIBLE ];
1624 $this->compatibility_info_by_feature[ $feature ][ FeaturePluginCompatibility::INCOMPATIBLE ] = array_diff( $incompatibles, array( $plugin_name ) );
1625 }
1626 }
1627
1628 /**
1629 * Handler for the all_plugins filter.
1630 *
1631 * Returns the list of plugins incompatible with a given plugin
1632 * if we are in the plugins page and the query string of the current request
1633 * looks like '?plugin_status=incompatible_with_feature&feature_id=<feature id>'.
1634 *
1635 * @param array $plugin_list The original list of plugins.
1636 *
1637 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1638 */
1639 public function filter_plugins_list( $plugin_list ): array {
1640 if ( ! $this->verify_did_woocommerce_init() ) {
1641 return $plugin_list;
1642 }
1643
1644 // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput
1645 if ( ! function_exists( 'get_current_screen' ) ||
1646 ( get_current_screen() && 'plugins' !== get_current_screen()->id ) ||
1647 'incompatible_with_feature' !== ArrayUtil::get_value_or_default( $_GET, 'plugin_status' ) ) {
1648 return $plugin_list;
1649 }
1650
1651 $feature_id = $_GET['feature_id'] ?? 'all';
1652 if ( 'all' !== $feature_id && ! $this->feature_exists( $feature_id ) ) {
1653 return $plugin_list;
1654 }
1655
1656 return $this->get_incompatible_plugins( $feature_id, $plugin_list );
1657 }
1658
1659 /**
1660 * Returns the list of plugins incompatible with a given feature.
1661 *
1662 * @param string $feature_id ID of the feature. Can also be `all` to denote all features.
1663 * @param array $plugin_list List of plugins to filter.
1664 *
1665 * @return array List of plugins incompatible with the given feature.
1666 */
1667 public function get_incompatible_plugins( $feature_id, $plugin_list ) {
1668 $incompatibles = array();
1669 $plugin_list = array_diff_key( $plugin_list, array_flip( $this->plugins_excluded_from_compatibility_ui ) );
1670 $feature_ids = 'all' === $feature_id ? array_keys( $this->get_feature_definitions() ) : array( $feature_id );
1671 $only_enabled_features = 'all' === $feature_id;
1672
1673 // phpcs:enable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput
1674 foreach ( array_keys( $plugin_list ) as $plugin_name ) {
1675 if ( ! $this->plugin_util->is_woocommerce_aware_plugin( $plugin_name ) || ! $this->proxy->call_function( 'is_plugin_active', $plugin_name ) ) {
1676 continue;
1677 }
1678
1679 $compatibility_info = $this->get_compatible_features_for_plugin( $plugin_name );
1680 foreach ( $feature_ids as $feature_id ) {
1681 $features_considered_incompatible = array_filter(
1682 $this->plugin_util->get_items_considered_incompatible( $feature_id, $compatibility_info ),
1683 $only_enabled_features ?
1684 fn( $id ) => $this->feature_is_enabled( $id ) && ! $this->should_skip_compatibility_checks( $id ) :
1685 fn( $id ) => ! $this->should_skip_compatibility_checks( $id )
1686 );
1687 if ( in_array( $feature_id, $features_considered_incompatible, true ) ) {
1688 $incompatibles[] = $plugin_name;
1689 }
1690 }
1691 }
1692
1693 return array_intersect_key( $plugin_list, array_flip( $incompatibles ) );
1694 }
1695
1696 /**
1697 * Handler for the admin_notices action.
1698 *
1699 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1700 */
1701 public function display_notices_in_plugins_page(): void {
1702 if ( ! $this->verify_did_woocommerce_init() ) {
1703 return;
1704 }
1705
1706 $feature_filter_description_shown = $this->maybe_display_current_feature_filter_description();
1707 if ( ! $feature_filter_description_shown ) {
1708 $this->maybe_display_feature_incompatibility_warning();
1709 }
1710 }
1711
1712 /**
1713 * Shows a warning when there are any incompatibility between active plugins and enabled features.
1714 * The warning is shown in on any admin screen except the plugins screen itself, since
1715 * there's already a "You are viewing plugins that are incompatible" notice.
1716 */
1717 private function maybe_display_feature_incompatibility_warning(): void {
1718 if ( ! current_user_can( 'activate_plugins' ) ) {
1719 return;
1720 }
1721
1722 $incompatible_plugins = false;
1723 $relevant_plugins = array_diff( $this->plugin_util->get_woocommerce_aware_plugins( true ), $this->plugins_excluded_from_compatibility_ui );
1724
1725 foreach ( $relevant_plugins as $plugin ) {
1726 $compatibility_info = $this->get_compatible_features_for_plugin( $plugin, true );
1727
1728 $incompatibles = array_filter( $compatibility_info[ FeaturePluginCompatibility::INCOMPATIBLE ], fn( $id ) => ! $this->should_skip_compatibility_checks( $id ) );
1729 if ( ! empty( $incompatibles ) ) {
1730 $incompatible_plugins = true;
1731 break;
1732 }
1733
1734 $uncertains = array_filter( $compatibility_info[ FeaturePluginCompatibility::UNCERTAIN ], fn( $id ) => ! $this->should_skip_compatibility_checks( $id ) );
1735 foreach ( $uncertains as $feature_id ) {
1736 if ( FeaturePluginCompatibility::COMPATIBLE !== $this->get_default_plugin_compatibility( $feature_id ) ) {
1737 $incompatible_plugins = true;
1738 break;
1739 }
1740 }
1741
1742 if ( $incompatible_plugins ) {
1743 break;
1744 }
1745 }
1746
1747 if ( ! $incompatible_plugins ) {
1748 return;
1749 }
1750
1751 $message = str_replace(
1752 '<a>',
1753 '<a href="' . esc_url( add_query_arg( array( 'plugin_status' => 'incompatible_with_feature' ), admin_url( 'plugins.php' ) ) ) . '">',
1754 __( 'WooCommerce has detected that some of your active plugins are incompatible with currently enabled WooCommerce features. Please <a>review the details</a>.', 'woocommerce' )
1755 );
1756
1757 // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
1758 ?>
1759 <div class="notice notice-error">
1760 <p><?php echo $message; ?></p>
1761 </div>
1762 <?php
1763 // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
1764 }
1765
1766 /**
1767 * Shows a "You are viewing the plugins that are incompatible with the X feature"
1768 * if we are in the plugins page and the query string of the current request
1769 * looks like '?plugin_status=incompatible_with_feature&feature_id=<feature id>'.
1770 */
1771 private function maybe_display_current_feature_filter_description(): bool {
1772 if ( 'plugins' !== get_current_screen()->id ) {
1773 return false;
1774 }
1775
1776 // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput
1777 $plugin_status = $_GET['plugin_status'] ?? '';
1778 $feature_id = $_GET['feature_id'] ?? '';
1779 // phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput
1780
1781 if ( 'incompatible_with_feature' !== $plugin_status ) {
1782 return false;
1783 }
1784
1785 $feature_id = ( '' === $feature_id ) ? 'all' : $feature_id;
1786
1787 if ( 'all' !== $feature_id && ! $this->feature_exists( $feature_id ) ) {
1788 return false;
1789 }
1790
1791 $features = $this->get_feature_definitions();
1792 $plugins_page_url = admin_url( 'plugins.php' );
1793 $features_page_url = $this->get_features_page_url();
1794
1795 $message =
1796 'all' === $feature_id
1797 ? __( 'You are viewing active plugins that are incompatible with currently enabled WooCommerce features.', 'woocommerce' )
1798 : sprintf(
1799 /* translators: %s is a feature name. */
1800 __( "You are viewing the active plugins that are incompatible with the '%s' feature.", 'woocommerce' ),
1801 $features[ $feature_id ]['name']
1802 );
1803
1804 $message .= '<br />';
1805 $message .= sprintf(
1806 __( "<a href='%1\$s'>View all plugins</a> - <a href='%2\$s'>Manage WooCommerce features</a>", 'woocommerce' ),
1807 $plugins_page_url,
1808 $features_page_url
1809 );
1810
1811 // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
1812 ?>
1813 <div class="notice notice-info">
1814 <p><?php echo $message; ?></p>
1815 </div>
1816 <?php
1817 // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
1818
1819 return true;
1820 }
1821
1822 /**
1823 * If the 'incompatible with features' plugin list is being rendered, invalidate existing cached plugin data.
1824 *
1825 * This heads off a problem in which WordPress's `get_plugins()` function may be called much earlier in the request
1826 * (by third party code, for example), the results of which are cached, and before WooCommerce can modify the list
1827 * to inject useful information of its own.
1828 *
1829 * @see https://github.com/woocommerce/woocommerce/issues/37343
1830 *
1831 * @return void
1832 *
1833 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1834 */
1835 public function maybe_invalidate_cached_plugin_data(): void {
1836 // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1837 if ( ( $_GET['plugin_status'] ?? '' ) === 'incompatible_with_feature' ) {
1838 wp_cache_delete( 'plugins', 'plugins' );
1839 }
1840 }
1841
1842 /**
1843 * Handler for the 'after_plugin_row' action.
1844 * Displays a "This plugin is incompatible with X features" notice if necessary.
1845 *
1846 * @param string $plugin_file The id of the plugin for which a row has been rendered in the plugins page.
1847 * @param array $plugin_data Plugin data, as returned by 'get_plugins'.
1848 *
1849 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1850 */
1851 public function handle_plugin_list_rows( $plugin_file, $plugin_data ) {
1852 global $wp_list_table;
1853
1854 if ( in_array( $plugin_file, $this->plugins_excluded_from_compatibility_ui, true ) ) {
1855 return;
1856 }
1857
1858 if ( 'incompatible_with_feature' !== ArrayUtil::get_value_or_default( $_GET, 'plugin_status' ) ) { // phpcs:ignore WordPress.Security.NonceVerification
1859 return;
1860 }
1861
1862 if ( is_null( $wp_list_table ) || ! $this->plugin_util->is_woocommerce_aware_plugin( $plugin_data ) ) {
1863 return;
1864 }
1865
1866 if ( ! $this->proxy->call_function( 'is_plugin_active', $plugin_file ) ) {
1867 return;
1868 }
1869
1870 $features = $this->get_feature_definitions();
1871 $feature_compatibility_info = $this->get_compatible_features_for_plugin( $plugin_file, true, true );
1872 $incompatible_features = $feature_compatibility_info[ FeaturePluginCompatibility::INCOMPATIBLE ];
1873 $incompatible_features = array_values(
1874 array_filter(
1875 $incompatible_features,
1876 function ( $feature_id ) {
1877 return ! $this->should_skip_compatibility_checks( $feature_id );
1878 }
1879 )
1880 );
1881
1882 $incompatible_features_count = count( $incompatible_features );
1883 if ( $incompatible_features_count > 0 ) {
1884 $columns_count = $wp_list_table->get_column_count();
1885 $is_active = true; // For now we are showing active plugins in the "Incompatible with..." view.
1886 $is_active_class = $is_active ? 'active' : 'inactive';
1887 $is_active_td_style = $is_active ? " style='border-left: 4px solid #72aee6;'" : '';
1888
1889 if ( 1 === $incompatible_features_count ) {
1890 $message = sprintf(
1891 /* translators: %s = printable plugin name */
1892 __( "⚠ This plugin is incompatible with the enabled WooCommerce feature '%s', it shouldn't be activated.", 'woocommerce' ),
1893 $features[ $incompatible_features[0] ]['name']
1894 );
1895 } elseif ( 2 === $incompatible_features_count ) {
1896 /* translators: %1\$s, %2\$s = printable plugin names */
1897 $message = sprintf(
1898 __( "⚠ This plugin is incompatible with the enabled WooCommerce features '%1\$s' and '%2\$s', it shouldn't be activated.", 'woocommerce' ),
1899 $features[ $incompatible_features[0] ]['name'],
1900 $features[ $incompatible_features[1] ]['name']
1901 );
1902 } else {
1903 /* translators: %1\$s, %2\$s = printable plugin names, %3\$d = plugins count */
1904 $message = sprintf(
1905 __( "⚠ This plugin is incompatible with the enabled WooCommerce features '%1\$s', '%2\$s' and %3\$d more, it shouldn't be activated.", 'woocommerce' ),
1906 $features[ $incompatible_features[0] ]['name'],
1907 $features[ $incompatible_features[1] ]['name'],
1908 $incompatible_features_count - 2
1909 );
1910 }
1911 $features_page_url = $this->get_features_page_url();
1912 $manage_features_message = __( 'Manage WooCommerce features', 'woocommerce' );
1913
1914 // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
1915 ?>
1916 <tr class='plugin-update-tr update <?php echo $is_active_class; ?>' data-plugin='<?php echo $plugin_file; ?>' data-plugin-row-type='feature-incomp-warn'>
1917 <td colspan='<?php echo $columns_count; ?>' class='plugin-update'<?php echo $is_active_td_style; ?>>
1918 <div class='notice inline notice-warning notice-alt'>
1919 <p>
1920 <?php echo $message; ?>
1921 <a href="<?php echo $features_page_url; ?>"><?php echo $manage_features_message; ?></a>
1922 </p>
1923 </div>
1924 </td>
1925 </tr>
1926 <?php
1927 // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
1928 }
1929 }
1930
1931 /**
1932 * Get the URL of the features settings page.
1933 *
1934 * @return string
1935 */
1936 public function get_features_page_url(): string {
1937 return admin_url( 'admin.php?page=wc-settings&tab=advanced&section=features' );
1938 }
1939
1940 /**
1941 * Fix for the HTML of the plugins list when there are feature-plugin incompatibility warnings.
1942 *
1943 * WordPress renders the plugin information rows in the plugins page in <tr> elements as follows:
1944 *
1945 * - If the plugin needs update, the <tr> will have an "update" class. This will prevent the lower
1946 * border line to be drawn. Later an additional <tr> with an "update available" warning will be rendered,
1947 * it will have a "plugin-update-tr" class which will draw the missing lower border line.
1948 * - Otherwise, the <tr> will be already drawn with the lower border line.
1949 *
1950 * This is a problem for our rendering of the "plugin is incompatible with X features" warning:
1951 *
1952 * - If the plugin info <tr> has "update", our <tr> will render nicely right after it; but then
1953 * our own "plugin-update-tr" class will draw an additional line before the "needs update" warning.
1954 * - If not, the plugin info <tr> will render its lower border line right before our compatibility info <tr>.
1955 *
1956 * This small script fixes this by adding the "update" class to the plugin info <tr> if it doesn't have it
1957 * (so no extra line before our <tr>), or removing 'plugin-update-tr' from our <tr> otherwise
1958 * (and then some extra manual tweaking of margins is needed).
1959 *
1960 * @param string $current_screen The current screen object.
1961 *
1962 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1963 */
1964 public function enqueue_script_to_fix_plugin_list_html( $current_screen ): void {
1965 if ( 'plugins' !== $current_screen->id ) {
1966 return;
1967 }
1968
1969 $handle = 'wc-features-fix-plugin-list-html';
1970 wp_register_script( $handle, '', array(), WC_VERSION, array( 'in_footer' => true ) );
1971 wp_enqueue_script( $handle );
1972 wp_add_inline_script(
1973 $handle,
1974 "
1975 const warningRows = document.querySelectorAll('tr[data-plugin-row-type=\"feature-incomp-warn\"]');
1976 for(const warningRow of warningRows) {
1977 const pluginName = warningRow.getAttribute('data-plugin');
1978 const pluginInfoRow = document.querySelector('tr.active[data-plugin=\"' + pluginName + '\"]:not(.plugin-update-tr), tr.inactive[data-plugin=\"' + pluginName + '\"]:not(.plugin-update-tr)');
1979 if(!pluginInfoRow) {
1980 continue;
1981 }
1982 if(pluginInfoRow.classList.contains('update')) {
1983 warningRow.classList.remove('plugin-update-tr');
1984 warningRow.querySelector('.notice').style.margin = '5px 10px 15px 30px';
1985 }
1986 else {
1987 pluginInfoRow.classList.add('update');
1988 }
1989 }
1990 "
1991 );
1992 }
1993
1994 /**
1995 * Handler for the 'views_plugins' hook that shows the links to the different views in the plugins page.
1996 * If we come from a "Manage incompatible plugins" in the features page we'll show just two views:
1997 * "All" (so that it's easy to go back to a known state) and "Incompatible with X".
1998 * We'll skip the rest of the views since the counts are wrong anyway, as we are modifying
1999 * the plugins list via the 'all_plugins' filter.
2000 *
2001 * @param array $views An array of view ids => view links.
2002 * @return string[] The actual views array to use.
2003 *
2004 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
2005 */
2006 public function handle_plugins_page_views_list( $views ): array {
2007 // phpcs:disable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput
2008 if ( 'incompatible_with_feature' !== ArrayUtil::get_value_or_default( $_GET, 'plugin_status' ) ) {
2009 return $views;
2010 }
2011
2012 $feature_id = $_GET['feature_id'] ?? 'all';
2013 if ( 'all' !== $feature_id && ! $this->feature_exists( $feature_id ) ) {
2014 return $views;
2015 }
2016 // phpcs:enable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput
2017
2018 $all_items = get_plugins();
2019 $features = $this->get_feature_definitions();
2020
2021 $incompatible_plugins_count = count( $this->filter_plugins_list( $all_items ) );
2022 $incompatible_text =
2023 'all' === $feature_id
2024 ? __( 'Incompatible with WooCommerce features', 'woocommerce' )
2025 /* translators: %s = name of a WooCommerce feature */
2026 : sprintf( __( "Incompatible with '%s'", 'woocommerce' ), $features[ $feature_id ]['name'] );
2027 $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>";
2028
2029 $all_plugins_count = count( $all_items );
2030 $all_text = __( 'All', 'woocommerce' );
2031 $all_link = "<a href='plugins.php?plugin_status=all'>{$all_text} <span class='count'>({$all_plugins_count})</span></a>";
2032
2033 return array(
2034 'all' => $all_link,
2035 'incompatible_with_feature' => $incompatible_link,
2036 );
2037 }
2038
2039 /**
2040 * Set the feature nonce to be sent from client side.
2041 *
2042 * @param array $settings Component settings.
2043 *
2044 * @return array
2045 *
2046 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
2047 */
2048 public function set_change_feature_enable_nonce( $settings ) {
2049 $settings['_feature_nonce'] = wp_create_nonce( 'change_feature_enable' );
2050 return $settings;
2051 }
2052
2053 /**
2054 * Changes the feature given it's id, a toggle value and nonce as a query param.
2055 *
2056 * `/wp-admin/post.php?feature_id=1&_feature_nonce=1234`, 1 for on
2057 * `/wp-admin/post.php?feature_id=0&_feature_nonce=1234`, 0 for off
2058 *
2059 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
2060 */
2061 public function change_feature_enable_from_query_params(): void {
2062 if ( ! current_user_can( 'manage_woocommerce' ) ) {
2063 return;
2064 }
2065
2066 $is_feature_nonce_invalid = ( ! isset( $_GET['_feature_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_feature_nonce'] ) ), 'change_feature_enable' ) );
2067
2068 $query_params_to_remove = array( '_feature_nonce' );
2069
2070 foreach ( array_keys( $this->get_feature_definitions() ) as $feature_id ) {
2071 if ( isset( $_GET[ $feature_id ] ) && is_numeric( $_GET[ $feature_id ] ) ) {
2072 $value = absint( $_GET[ $feature_id ] );
2073
2074 if ( $is_feature_nonce_invalid ) {
2075 wp_die( esc_html__( 'Action failed. Please refresh the page and retry.', 'woocommerce' ) );
2076 return;
2077 }
2078
2079 if ( 1 === $value ) {
2080 $this->change_feature_enable( $feature_id, true );
2081 } elseif ( 0 === $value ) {
2082 $this->change_feature_enable( $feature_id, false );
2083 }
2084 $query_params_to_remove[] = $feature_id;
2085 }
2086 }
2087 if ( count( $query_params_to_remove ) > 1 && isset( $_SERVER['REQUEST_URI'] ) ) {
2088 // phpcs:disable WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
2089 wp_safe_redirect( remove_query_arg( $query_params_to_remove, $_SERVER['REQUEST_URI'] ) );
2090 }
2091 }
2092
2093 /**
2094 * Display the email improvements feedback notice to render CES modal in.
2095 *
2096 * @param string $feature_id The feature id.
2097 * @param bool $is_enabled Whether the feature is enabled.
2098 *
2099 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
2100 */
2101 public function display_email_improvements_feedback_notice( $feature_id, $is_enabled ): void {
2102 if ( 'email_improvements' === $feature_id && ! $is_enabled ) {
2103 set_transient( 'wc_settings_email_improvements_reverted', 'yes', 15 );
2104 add_action(
2105 'admin_notices',
2106 function () {
2107 echo '<div id="wc_settings_features_email_feedback_slotfill"></div>';
2108 }
2109 );
2110 }
2111 }
2112
2113 /**
2114 * Flag a one-shot transient when the merchant turns on Abandoned cart recovery.
2115 *
2116 * `change_feature_enable` fires this action mid-request before the post-save
2117 * redirect, so the actual notice has to render on the next page load. We
2118 * stash a transient here and `maybe_render_abandoned_cart_recovery_enabled_notice`
2119 * picks it up the next time `woocommerce_settings_advanced` fires.
2120 *
2121 * @param string $feature_id Feature being toggled.
2122 * @param bool $is_enabled True when turned on, false when turned off.
2123 *
2124 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
2125 */
2126 public function flag_abandoned_cart_recovery_enabled_notice( $feature_id, $is_enabled ): void {
2127 if ( 'abandoned_cart_recovery' === $feature_id && $is_enabled ) {
2128 set_transient( 'wc_abandoned_cart_recovery_enabled_notice', 'yes', MINUTE_IN_SECONDS );
2129 }
2130 }
2131
2132 /**
2133 * Render a success notice after the merchant enables Abandoned cart recovery,
2134 * pointing them straight at the email settings page where they actually
2135 * configure it.
2136 *
2137 * Hooks into `woocommerce_settings_advanced` at priority 1, which fires
2138 * inside the settings template right after `WC_Admin_Settings::show_messages()`
2139 * (the "Your settings have been saved." notice) and before the form fields.
2140 * That places our notice in the same visual slot below the tabs, alongside
2141 * the standard save confirmation.
2142 *
2143 * `WC_Admin_Settings::add_message()` would be the cleaner API but escapes
2144 * its input via `esc_html()`, which strips the link tag. Direct echo here
2145 * keeps the markup intact while still matching the surrounding notice style.
2146 *
2147 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
2148 */
2149 public function maybe_render_abandoned_cart_recovery_enabled_notice(): void {
2150 if ( 'yes' !== get_transient( 'wc_abandoned_cart_recovery_enabled_notice' ) ) {
2151 return;
2152 }
2153 delete_transient( 'wc_abandoned_cart_recovery_enabled_notice' );
2154
2155 $settings_url = admin_url( 'admin.php?page=wc-settings&tab=email&section=wc_email_customer_abandoned_cart_recovery' );
2156
2157 printf(
2158 '<div id="wc-abandoned-cart-recovery-enabled-notice" class="updated inline"><p><strong>%1$s <a href="%2$s">%3$s</a></strong></p></div>',
2159 esc_html__( 'Abandoned cart recovery is enabled.', 'woocommerce' ),
2160 esc_url( $settings_url ),
2161 esc_html__( 'Configure the recovery email →', 'woocommerce' )
2162 );
2163 }
2164
2165 /**
2166 * Check if the email improvements feature is enabled in preview mode in Settings > Emails.
2167 * This is used to force the email improvements feature without affecting shoppers.
2168 *
2169 * @param string $feature_id The feature id.
2170 * @return bool Whether the email improvements feature is enabled in preview mode.
2171 */
2172 private function is_preview_email_improvements_enabled( string $feature_id ): bool {
2173 if ( 'email_improvements' !== $feature_id ) {
2174 return false;
2175 }
2176 /**
2177 * This filter is documented in templates/emails/email-styles.php
2178 *
2179 * @since 9.9.0
2180 * @param bool $is_email_preview Whether the email is being previewed.
2181 */
2182 $is_email_preview = apply_filters( 'woocommerce_is_email_preview', false );
2183 if ( $is_email_preview ) {
2184 return get_transient( EmailPreview::TRANSIENT_PREVIEW_EMAIL_IMPROVEMENTS ) === 'yes';
2185 }
2186 return false;
2187 }
2188 }
2189