PluginProbe ʕ •ᴥ•ʔ
Elementor Website Builder – more than just a page builder / 3.29.0-beta1
Elementor Website Builder – more than just a page builder v3.29.0-beta1
4.1.1 4.1.0 4.1.0-beta3 4.1.0-dev3 4.0.9 4.1.0-beta2 4.1.0-dev2 4.0.8 4.1.0-beta1 4.1.0-dev1 4.0.7 4.0.6 4.0.5 4.0.4 4.0.3 3.22.0-dev1 4.0.0-beta3 3.22.0-dev2 4.0.0-beta4 3.22.0-dev3 4.0.0-beta5 3.22.0-dev4 4.0.0-dev1 3.22.0-dev5 4.0.0-dev2 3.22.0-dev6 4.0.0-dev3 3.22.1 4.0.0-dev4 3.22.2 4.0.0-dev5 3.22.3 4.0.1 3.23.0 4.0.2 3.23.0-beta1 3.23.0-beta2 3.23.0-beta3 3.23.0-beta4 3.23.0-beta5 3.23.0-beta6 3.23.0-dev1 3.23.0-dev2 3.23.0-dev3 3.23.0-dev4 3.23.0-dev5 3.23.0-dev6 3.23.1 3.23.2 3.23.3 3.23.4 3.24.0 3.24.0-beta1 3.24.0-beta2 3.24.0-beta3 3.24.0-dev1 3.24.0-dev2 3.24.0-dev3 3.24.1 3.24.2 3.24.3 3.24.4 3.24.5 3.24.6 3.24.7 3.24.8 3.25.0 3.25.0-beta1 3.25.0-beta2 3.25.0-beta3 3.25.0-dev1 3.25.0-dev2 3.25.0-dev3 3.25.1 3.25.10 3.25.11 3.25.2 3.25.3 3.25.4 3.25.5 3.25.6 3.25.7 3.25.8 3.25.9 3.26.0 3.26.0-beta1 3.26.0-beta2 3.26.0-beta3 3.26.0-beta4 3.26.0-beta5 3.26.0-dev1 3.26.0-dev2 3.26.0-dev3 3.26.0-dev4 3.26.0-dev5 3.26.1 3.26.2 3.26.3 3.26.4 3.26.5 3.27.0 3.27.0-beta1 3.27.0-beta2 3.27.0-dev1 3.27.0-dev2 3.27.1 3.27.2 3.27.3 3.27.4 3.27.5 3.27.6 3.27.7 3.28.0 3.28.0-beta1 3.28.0-beta2 3.28.0-beta3 3.28.0-dev1 3.28.0-dev2 3.28.0-dev3 3.28.1 3.28.2 3.28.3 3.28.4 3.29.0 3.29.0-beta1 trunk 3.29.0-beta2 3.0.0 3.29.0-beta3 3.0.1 3.29.0-beta4 3.0.10 3.29.0-dev1 3.0.11 3.29.0-dev2 3.0.12 3.29.0-dev3 3.0.13 3.29.0-dev4 3.0.14 3.29.1 3.0.15 3.29.2 3.0.16 3.3.0 3.0.2 3.3.1 3.0.3 3.30.0 3.0.4 3.30.0-beta1 3.0.5 3.30.0-beta2 3.0.6 3.30.0-beta3 3.0.7 3.30.0-dev1 3.0.8 3.30.0-dev2 3.0.8.1 3.30.0-dev3 3.0.9 3.30.1 3.1.0 3.30.2 3.1.0-beta1 3.30.3 3.1.0-beta2 3.30.4 3.1.0-beta3 3.31.0 3.1.0-beta4 3.31.0-beta1 3.1.0-dev1 3.31.0-beta2 3.1.0-dev2 3.31.0-dev1 3.1.0-dev3 3.31.0-dev2 3.1.1 3.31.1 3.1.2 3.31.2 3.1.3 3.31.3 3.1.4 3.31.4 3.10.0 3.31.5 3.10.0-dev1 3.32.0 3.10.1 3.32.0-beta1 3.10.2 3.32.0-beta2 3.11.0 3.32.0-beta3 3.11.0-beta1 3.32.0-dev1 3.11.0-beta2 3.32.0-dev2 3.11.0-beta3 3.32.0-dev3 3.11.0-dev1 3.32.1 3.11.0-dev2 3.32.2 3.11.0-dev3 3.32.3 3.11.1 3.32.4 3.11.2 3.32.5 3.11.3 3.33.0 3.11.4 3.33.0-beta1 3.11.5 3.33.0-beta2 3.12.0 3.33.0-beta3 3.12.1 3.33.0-beta4 3.12.2 3.33.0-dev1 3.13.0 3.33.0-dev2 3.13.0-beta1 3.33.0-dev3 3.13.0-beta2 3.33.0-dev4 3.13.0-beta3 3.33.1 3.13.0-dev3 3.33.2 3.13.0-dev4 3.33.3 3.13.1 3.33.4 3.13.2 3.33.5 3.13.3 3.33.6 3.13.4 3.34.0 3.14.0 3.34.0-beta1 3.14.0-beta1 3.34.0-beta2 3.14.0-beta2 3.34.0-beta3 3.14.0-beta3 3.34.0-dev1 3.14.0-beta4 3.34.0-dev2 3.14.0-beta5 3.34.1 3.14.1 3.34.2 3.15.0 3.34.3 3.15.1 3.34.4 3.15.2 3.35.0 3.15.3 3.35.0-beta1 3.16.0 3.35.0-beta2 3.16.0-beta3 3.35.0-beta3 3.16.0-beta4 3.35.0-beta4 3.16.0-dev1 3.35.0-dev1 3.16.0-dev2 3.35.0-dev2 3.16.1 3.35.0-dev3 3.16.2 3.35.0-dev4 3.16.3 3.35.1 3.16.4 3.35.2 3.16.5 3.35.3 3.16.6 3.35.4 3.17.0 3.35.5 3.17.0-dev2 3.35.6 3.17.0-dev3 3.35.7 3.17.0-dev4 3.35.8 3.17.1 3.35.9 3.17.2 3.4.0 3.17.3 3.4.0-dev7 3.18.0 3.4.0-dev8 3.18.0-beta1 3.4.0-dev9 3.18.0-beta2 3.4.1 3.18.0-beta3 3.4.2 3.18.0-beta4 3.4.3 3.18.0-dev1 3.4.4 3.18.1 3.4.5 3.18.2 3.4.6 3.18.3 3.4.7 3.19.0 3.4.8 3.19.0-beta1 3.5.0 3.19.0-beta2 3.5.0-beta1 3.19.0-beta3 3.5.0-beta2 3.19.0-beta4 3.5.0-beta3 3.19.0-beta5 3.5.0-beta4 3.19.0-beta6 3.5.0-beta5 3.19.0-dev1 3.5.0-beta7 3.19.0-dev2 3.5.0-beta8 3.19.0-dev3 3.5.0-dev8 3.19.0-dev4 3.5.0-dev9 3.19.0-dev5 3.5.1 3.19.0-dev6 3.5.2 3.19.1 3.5.3 3.19.2 3.5.4 3.19.3 3.5.5 3.19.4 3.5.6 3.2.0 3.6.0 3.2.1 3.6.0-beta1 3.2.2 3.6.0-beta2 3.2.3 3.6.0-beta3 3.2.4 3.6.0-beta4 3.2.5 3.6.0-beta5 3.20.0 3.6.0-dev1 3.20.0-beta1 3.6.0-dev10 3.20.0-beta2 3.6.1 3.20.0-beta3 3.6.2 3.20.0-beta4 3.6.3 3.20.0-dev1 3.6.4 3.20.0-dev2 3.6.5 3.20.0-dev3 3.6.6 3.20.0-dev4 3.6.7 3.20.1 3.6.8 3.20.2 3.7.0 3.20.3 3.7.0-beta1 3.20.4 3.7.0-beta2 3.21.0 3.7.0-beta3 3.21.0-beta1 3.7.0-beta4 3.21.0-beta2 3.7.0-dev1 3.21.0-beta3 3.7.1 3.21.0-dev1 3.7.2 3.21.0-dev2 3.7.3 3.21.0-dev3 3.7.4 3.21.1 3.7.5 3.21.2 3.7.6 3.21.3 3.7.7 3.21.4 3.7.8 3.21.5 3.8.0 3.21.6 3.8.0-beta1 3.21.7 3.8.0-beta2 3.21.8 3.8.0-beta3 3.22.0 3.8.1 3.22.0-beta1 3.9.0 3.22.0-beta2 3.9.1 3.22.0-beta3 3.9.2 3.22.0-beta4 4.0.0 3.22.0-beta5 4.0.0-beta1 3.22.0-beta6 4.0.0-beta2
elementor / core / experiments / manager.php
elementor / core / experiments Last commit date
exceptions 1 year ago experiments-reporter.php 3 years ago manager.php 1 year ago non-existing-dependency.php 1 year ago wp-cli.php 1 year ago wrap-core-dependency.php 1 year ago
manager.php
1048 lines
1 <?php
2 namespace Elementor\Core\Experiments;
3
4 use Elementor\Core\Base\Base_Object;
5 use Elementor\Core\Experiments\Exceptions\Dependency_Exception;
6 use Elementor\Core\Upgrade\Manager as Upgrade_Manager;
7 use Elementor\Core\Utils\Collection;
8 use Elementor\Modules\System_Info\Module as System_Info;
9 use Elementor\Plugin;
10 use Elementor\Settings;
11 use Elementor\Tracker;
12 use Elementor\Utils;
13
14 if ( ! defined( 'ABSPATH' ) ) {
15 exit; // Exit if accessed directly.
16 }
17
18 class Manager extends Base_Object {
19
20 const RELEASE_STATUS_DEV = 'dev';
21
22 const RELEASE_STATUS_ALPHA = 'alpha';
23
24 const RELEASE_STATUS_BETA = 'beta';
25
26 const RELEASE_STATUS_STABLE = 'stable';
27
28 const STATE_DEFAULT = 'default';
29
30 const STATE_ACTIVE = 'active';
31
32 const STATE_INACTIVE = 'inactive';
33
34 const TYPE_HIDDEN = 'hidden';
35
36 const OPTION_PREFIX = 'elementor_experiment-';
37
38 private $states;
39
40 private $release_statuses;
41
42 private $features;
43
44 /**
45 * Add Feature
46 *
47 * Each feature has to provide the following information:
48 * [
49 * 'name' => string,
50 * 'title' => string,
51 * 'description' => string,
52 * 'tag' => string,
53 * 'release_status' => string,
54 * 'default' => string,
55 * 'new_site' => array,
56 * ]
57 *
58 * @since 3.1.0
59 * @access public
60 *
61 * @param array $options Feature options.
62 * @return array|null
63 *
64 * @throws Dependency_Exception If can't change feature state.
65 */
66 public function add_feature( array $options ) {
67 if ( isset( $this->features[ $options['name'] ] ) ) {
68 return null;
69 }
70
71 $experimental_data = $this->set_feature_initial_options( $options );
72
73 $new_site = $experimental_data['new_site'];
74
75 if ( $new_site['default_active'] || $new_site['always_active'] || $new_site['default_inactive'] ) {
76 $experimental_data = $this->set_new_site_default_state( $new_site, $experimental_data );
77 }
78
79 if ( $experimental_data['mutable'] ) {
80 $experimental_data['state'] = $this->get_saved_feature_state( $options['name'] );
81 }
82
83 if ( empty( $experimental_data['state'] ) ) {
84 $experimental_data['state'] = self::STATE_DEFAULT;
85 }
86
87 if ( ! empty( $experimental_data['dependencies'] ) ) {
88 $experimental_data = $this->initialize_feature_dependencies( $experimental_data );
89 }
90
91 $this->features[ $options['name'] ] = $experimental_data;
92
93 if ( $experimental_data['mutable'] && is_admin() ) {
94 $feature_option_key = $this->get_feature_option_key( $options['name'] );
95
96 $on_state_change_callback = function( $old_state, $new_state ) use ( $experimental_data, $feature_option_key ) {
97 try {
98 $this->on_feature_state_change( $experimental_data, $new_state, $old_state );
99 } catch ( Exceptions\Dependency_Exception $e ) {
100 $message = sprintf(
101 '<p>%s</p><p><a href="#" onclick="location.href=\'%s\'">%s</a></p>',
102 esc_html( $e->getMessage() ),
103 Settings::get_settings_tab_url( 'experiments' ),
104 esc_html__( 'Back', 'elementor' )
105 );
106
107 wp_die( $message ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
108 }
109 };
110
111 add_action( 'add_option_' . $feature_option_key, $on_state_change_callback, 10, 2 );
112 add_action( 'update_option_' . $feature_option_key, $on_state_change_callback, 10, 2 );
113 }
114
115 do_action( 'elementor/experiments/feature-registered', $this, $experimental_data );
116
117 return $experimental_data;
118 }
119
120 private function install_compare( $version ) {
121 $installs_history = Upgrade_Manager::get_installs_history();
122
123 if ( empty( $installs_history ) ) {
124 return false;
125 }
126
127 $cleaned_version = preg_replace( '/-(beta|cloud|dev)\d*$/', '', key( $installs_history ) );
128
129 return version_compare(
130 $cleaned_version,
131 $version,
132 '>='
133 );
134 }
135
136 /**
137 * Combine 'tag' and 'tags' into one property.
138 *
139 * @param array $experimental_data
140 *
141 * @return array
142 */
143 private function unify_feature_tags( array $experimental_data ): array {
144 foreach ( [ 'tag', 'tags' ] as $key ) {
145 if ( empty( $experimental_data[ $key ] ) ) {
146 continue;
147 }
148
149 $experimental_data[ $key ] = $this->format_feature_tags( $experimental_data[ $key ] );
150 }
151
152 if ( is_array( $experimental_data['tag'] ) ) {
153 $experimental_data['tags'] = array_merge( $experimental_data['tag'], $experimental_data['tags'] );
154 }
155
156 return $experimental_data;
157 }
158
159 /**
160 * Format feature tags into the right format.
161 *
162 * If an array of tags provided, each tag has to provide the following information:
163 * [
164 * [
165 * 'type' => string,
166 * 'label' => string,
167 * ]
168 * ]
169 *
170 * @param string|array $tags A string of comma separated tags, or an array of tags.
171 *
172 * @return array
173 */
174 private function format_feature_tags( $tags ): array {
175 if ( ! is_string( $tags ) && ! is_array( $tags ) ) {
176 return [];
177 }
178
179 $default_tag = [
180 'type' => 'default',
181 'label' => '',
182 ];
183
184 $allowed_tag_properties = [ 'type', 'label' ];
185
186 // If $tags is string, explode by commas and convert to array.
187 if ( is_string( $tags ) ) {
188 $tags = array_filter( explode( ',', $tags ) );
189
190 foreach ( $tags as $i => $tag ) {
191 $tags[ $i ] = [ 'label' => trim( $tag ) ];
192 }
193 }
194
195 foreach ( $tags as $i => $tag ) {
196 if ( empty( $tag['label'] ) ) {
197 unset( $tags[ $i ] );
198 continue;
199 }
200
201 $tags[ $i ] = $this->merge_properties( $default_tag, $tag, $allowed_tag_properties );
202 }
203
204 return $tags;
205 }
206
207 /**
208 * Remove Feature
209 *
210 * @since 3.1.0
211 * @access public
212 *
213 * @param string $feature_name
214 */
215 public function remove_feature( $feature_name ) {
216 unset( $this->features[ $feature_name ] );
217 }
218
219 /**
220 * Get Features
221 *
222 * @since 3.1.0
223 * @access public
224 *
225 * @param string $feature_name Optional. Default is null.
226 *
227 * @return array|null
228 */
229 public function get_features( $feature_name = null ) {
230 return self::get_items( $this->features, $feature_name );
231 }
232
233 /**
234 * Get Active Features
235 *
236 * @since 3.1.0
237 * @access public
238 *
239 * @return array
240 */
241 public function get_active_features() {
242 return array_filter( $this->features, [ $this, 'is_feature_active' ], ARRAY_FILTER_USE_KEY );
243 }
244
245 /**
246 * Is Feature Active
247 *
248 * @since 3.1.0
249 * @access public
250 *
251 * @param string $feature_name
252 *
253 * @return bool
254 */
255 public function is_feature_active( $feature_name, $check_dependencies = false ) {
256 $feature = $this->get_features( $feature_name );
257
258 if ( ! $feature || self::STATE_ACTIVE !== $this->get_feature_actual_state( $feature ) ) {
259 return false;
260 }
261
262 if ( $check_dependencies && isset( $feature['dependencies'] ) && is_array( $feature['dependencies'] ) ) {
263 foreach ( $feature['dependencies'] as $dependency ) {
264 $dependent_feature = $this->get_features( $dependency->get_name() );
265 $feature_state = self::STATE_ACTIVE === $this->get_feature_actual_state( $dependent_feature );
266
267 if ( ! $feature_state ) {
268 return false;
269 }
270 }
271 }
272
273 return true;
274 }
275
276 /**
277 * Set Feature Default State
278 *
279 * @since 3.1.0
280 * @access public
281 *
282 * @param string $feature_name
283 * @param string $default_state
284 */
285 public function set_feature_default_state( $feature_name, $default_state ) {
286 $feature = $this->get_features( $feature_name );
287
288 if ( ! $feature ) {
289 return;
290 }
291
292 $this->features[ $feature_name ]['default'] = $default_state;
293 }
294
295 /**
296 * Get Feature Option Key
297 *
298 * @since 3.1.0
299 * @access public
300 *
301 * @param string $feature_name
302 *
303 * @return string
304 */
305 public function get_feature_option_key( $feature_name ) {
306 return static::OPTION_PREFIX . $feature_name;
307 }
308
309 private function add_default_features() {
310 $this->add_feature( [
311 'name' => 'e_font_icon_svg',
312 'title' => esc_html__( 'Inline Font Icons', 'elementor' ),
313 'tag' => esc_html__( 'Performance', 'elementor' ),
314 'description' => sprintf(
315 '%1$s <a href="https://go.elementor.com/wp-dash-inline-font-awesome/" target="_blank">%2$s</a>',
316 esc_html__( 'The “Inline Font Icons” will render the icons as inline SVG without loading the Font-Awesome and the eicons libraries and its related CSS files and fonts.', 'elementor' ),
317 esc_html__( 'Learn more', 'elementor' )
318 ),
319 'release_status' => self::RELEASE_STATUS_STABLE,
320 'new_site' => [
321 'default_active' => true,
322 'minimum_installation_version' => '3.17.0',
323 ],
324 'generator_tag' => true,
325 ] );
326
327 $this->add_feature( [
328 'name' => 'additional_custom_breakpoints',
329 'title' => esc_html__( 'Additional Custom Breakpoints', 'elementor' ),
330 'description' => sprintf(
331 '%1$s <a href="https://go.elementor.com/wp-dash-additional-custom-breakpoints/" target="_blank">%2$s</a>',
332 esc_html__( 'Get pixel-perfect design for every screen size. You can now add up to 6 customizable breakpoints beyond the default desktop setting: mobile, mobile extra, tablet, tablet extra, laptop, and widescreen.', 'elementor' ),
333 esc_html__( 'Learn more', 'elementor' )
334 ),
335 'release_status' => self::RELEASE_STATUS_STABLE,
336 'default' => self::STATE_ACTIVE,
337 'generator_tag' => true,
338 ] );
339
340 $this->add_feature( [
341 'name' => 'container',
342 'title' => esc_html__( 'Container', 'elementor' ),
343 'description' => sprintf(
344 esc_html__( 'Create advanced layouts and responsive designs with %1$sFlexbox%2$s and %3$sGrid%4$s container elements. Give it a try using the %5$sContainer playground%6$s.', 'elementor' ),
345 '<a target="_blank" href="https://go.elementor.com/wp-dash-flex-container/">',
346 '</a>',
347 '<a target="_blank" href="https://go.elementor.com/wp-dash-grid-container/">',
348 '</a>',
349 '<a target="_blank" href="https://go.elementor.com/wp-dash-flex-container-playground/">',
350 '</a>'
351 ),
352 'release_status' => self::RELEASE_STATUS_STABLE,
353 'default' => self::STATE_INACTIVE,
354 'new_site' => [
355 'default_active' => true,
356 'minimum_installation_version' => '3.16.0',
357 ],
358 'messages' => [
359 'on_deactivate' => sprintf(
360 '%1$s <a target="_blank" href="https://go.elementor.com/wp-dash-deactivate-container/">%2$s</a>',
361 esc_html__( 'Container-based content will be hidden from your site and may not be recoverable in all cases.', 'elementor' ),
362 esc_html__( 'Learn more', 'elementor' ),
363 ),
364 ],
365 ] );
366
367 $this->add_feature( [
368 'name' => 'e_optimized_markup',
369 'title' => esc_html__( 'Optimized Markup', 'elementor' ),
370 'tag' => esc_html__( 'Performance', 'elementor' ),
371 'description' => esc_html__( 'Reduce the DOM size by eliminating HTML tags in various elements and widgets. This experiment includes markup changes so it might require updating custom CSS/JS code and cause compatibility issues with third party plugins.', 'elementor' ),
372 'release_status' => self::RELEASE_STATUS_BETA,
373 'default' => self::STATE_INACTIVE,
374 ] );
375
376 $this->add_feature( [
377 'name' => 'e_local_google_fonts',
378 'title' => esc_html__( 'Load Google Fonts locally', 'elementor' ),
379 'description' => esc_html__( "To improve page load performance and user privacy, replace Google Fonts CDN links with self-hosted font files. This approach downloads and serves font files directly from your server, eliminating external requests to Google's servers.", 'elementor' ),
380 'tag' => esc_html__( 'Performance', 'elementor' ),
381 'release_status' => self::RELEASE_STATUS_STABLE,
382 'generator_tag' => true,
383 'default' => self::STATE_ACTIVE,
384 ] );
385 }
386
387 /**
388 * Init States
389 *
390 * @since 3.1.0
391 * @access private
392 */
393 private function init_states() {
394 $this->states = [
395 self::STATE_DEFAULT => esc_html__( 'Default', 'elementor' ),
396 self::STATE_ACTIVE => esc_html__( 'Active', 'elementor' ),
397 self::STATE_INACTIVE => esc_html__( 'Inactive', 'elementor' ),
398 ];
399 }
400
401 /**
402 * Init Statuses
403 *
404 * @since 3.1.0
405 * @access private
406 */
407 private function init_release_statuses() {
408 $this->release_statuses = [
409 self::RELEASE_STATUS_DEV => esc_html__( 'Development', 'elementor' ),
410 self::RELEASE_STATUS_ALPHA => esc_html__( 'Alpha', 'elementor' ),
411 self::RELEASE_STATUS_BETA => esc_html__( 'Beta', 'elementor' ),
412 self::RELEASE_STATUS_STABLE => esc_html__( 'Stable', 'elementor' ),
413 ];
414 }
415
416 /**
417 * Init Features
418 *
419 * @since 3.1.0
420 * @access private
421 */
422 private function init_features() {
423 $this->features = [];
424
425 $this->add_default_features();
426
427 do_action( 'elementor/experiments/default-features-registered', $this );
428 }
429
430 /**
431 * Register Settings Fields
432 *
433 * @param Settings $settings
434 *
435 * @since 3.1.0
436 * @access private
437 */
438 private function register_settings_fields( Settings $settings ) {
439 $features = $this->get_features();
440
441 $fields = [];
442
443 foreach ( $features as $feature_name => $feature ) {
444 $is_hidden = $feature[ static::TYPE_HIDDEN ];
445 $is_mutable = $feature['mutable'];
446 $should_hide_experiment = ! $is_mutable || ( $is_hidden && ! $this->should_show_hidden() ) || $this->has_non_existing_dependency( $feature );
447
448 if ( $should_hide_experiment ) {
449 unset( $features[ $feature_name ] );
450
451 continue;
452 }
453
454 $feature_key = 'experiment-' . $feature_name;
455
456 $section = 'stable' === $feature['release_status'] ? 'stable' : 'ongoing';
457
458 $fields[ $section ][ $feature_key ]['label'] = $this->get_feature_settings_label_html( $feature );
459
460 $fields[ $section ][ $feature_key ]['field_args'] = $feature;
461
462 $fields[ $section ][ $feature_key ]['render'] = function( $feature ) {
463 $this->render_feature_settings_field( $feature );
464 };
465 }
466
467 foreach ( [ 'stable', 'ongoing' ] as $section ) {
468 if ( ! isset( $fields[ $section ] ) ) {
469 $fields[ $section ]['no_features'] = [
470 'label' => esc_html__( 'No available experiments', 'elementor' ),
471 'field_args' => [
472 'type' => 'raw_html',
473 'html' => esc_html__( 'The current version of Elementor doesn\'t have any experimental features . if you\'re feeling curious make sure to come back in future versions.', 'elementor' ),
474 ],
475 ];
476 }
477
478 if ( ! Tracker::is_allow_track() && 'stable' === $section ) {
479 $fields[ $section ] += $settings->get_usage_fields();
480 }
481 }
482
483 $settings->add_tab(
484 'experiments', [
485 'label' => esc_html__( 'Features', 'elementor' ),
486 'sections' => [
487 'ongoing_experiments' => [
488 'callback' => function() {
489 $this->render_settings_intro();
490 },
491 'fields' => $fields['ongoing'],
492 ],
493 'stable_experiments' => [
494 'callback' => function() {
495 $this->render_stable_section_title();
496 },
497 'fields' => $fields['stable'],
498 ],
499 ],
500 ]
501 );
502 }
503
504 private function render_stable_section_title() {
505 ?>
506 <hr>
507 <h2>
508 <?php echo esc_html__( 'Stable Features', 'elementor' ); ?>
509 </h2>
510 <?php
511 }
512
513 /**
514 * Render Settings Intro
515 *
516 * @since 3.1.0
517 * @access private
518 */
519 private function render_settings_intro() {
520 ?>
521 <h2>
522 <?php echo esc_html__( 'Experiments and Features', 'elementor' ); ?>
523 </h2>
524 <p class="e-experiment__description">
525 <?php
526 printf(
527 /* translators: %1$s Link open tag, %2$s: Link close tag. */
528 esc_html__( 'Personalize your Elementor experience by controlling which features and experiments are active on your site. Help make Elementor better by %1$ssharing your experience and feedback with us%2$s.', 'elementor' ),
529 '<a href="https://go.elementor.com/wp-dash-experiments-report-an-issue/" target="_blank">',
530 '</a>'
531 );
532 ?>
533 </p>
534 <p class="e-experiment__description">
535 <?php
536 printf(
537 '%1$s <a href="https://go.elementor.com/wp-dash-experiments/" target="_blank">%2$s</a>',
538 esc_html__( 'To use an experiment or feature on your site, simply click on the dropdown next to it and switch to Active. You can always deactivate them at any time.', 'elementor' ),
539 esc_html__( 'Learn more', 'elementor' ),
540 );
541 ?>
542 </p>
543
544 <?php if ( $this->get_features() ) { ?>
545 <button type="button" class="button e-experiment__button" value="active"><?php echo esc_html__( 'Activate All', 'elementor' ); ?></button>
546 <button type="button" class="button e-experiment__button" value="inactive"><?php echo esc_html__( 'Deactivate All', 'elementor' ); ?></button>
547 <button type="button" class="button e-experiment__button" value="default"><?php echo esc_html__( 'Back to default', 'elementor' ); ?></button>
548 <?php } ?>
549 <hr>
550 <h2 class="e-experiment__table-title">
551 <?php echo esc_html__( 'Ongoing Experiments', 'elementor' ); ?>
552 </h2>
553 <?php
554 }
555
556 /**
557 * Render Feature Settings Field
558 *
559 * @since 3.1.0
560 * @access private
561 *
562 * @param array $feature
563 */
564 private function render_feature_settings_field( array $feature ) {
565 $control_id = 'e-experiment-' . $feature['name'];
566 $control_name = $this->get_feature_option_key( $feature['name'] );
567
568 $status = sprintf(
569 /* translators: %s Release status. */
570 esc_html__( 'Status: %s', 'elementor' ),
571 $this->release_statuses[ $feature['release_status'] ]
572 );
573
574 ?>
575 <div class="e-experiment__content">
576 <select class="e-experiment__select"
577 id="<?php echo esc_attr( $control_id ); ?>"
578 name="<?php echo esc_attr( $control_name ); ?>"
579 data-experiment-id="<?php echo esc_attr( $feature['name'] ); ?>"
580 >
581 <?php foreach ( $this->states as $state_key => $state_title ) { ?>
582 <option value="<?php echo esc_attr( $state_key ); ?>"
583 <?php selected( $state_key, $feature['state'] ); ?>
584 >
585 <?php echo esc_html( $state_title ); ?>
586 </option>
587 <?php } ?>
588 </select>
589
590 <p class="description">
591 <?php Utils::print_unescaped_internal_string( $feature['description'] ); ?>
592 </p>
593
594 <?php $this->render_feature_dependency( $feature ); ?>
595
596 <?php if ( 'stable' !== $feature['release_status'] ) { ?>
597 <div class="e-experiment__status">
598 <?php echo esc_html( $status ); ?>
599 </div>
600 <?php } ?>
601 </div>
602 <?php
603 }
604
605 private function render_feature_dependency( $feature ) {
606 $dependencies = ( new Collection( $feature['dependencies'] ?? [] ) )
607 ->map( function ( $dependency ) {
608 return $dependency->get_title();
609 } )
610 ->implode( ', ' );
611
612 if ( empty( $dependencies ) ) {
613 return;
614 }
615
616 ?>
617 <div class="e-experiment__dependency">
618 <strong class="e-experiment__dependency__title"><?php echo esc_html__( 'Requires', 'elementor' ); ?>:</strong>
619 <span><?php echo esc_html( $dependencies ); ?></span>
620 </div>
621 <?php
622 }
623
624 private function has_non_existing_dependency( $feature ) {
625 $non_existing_dep = ( new Collection( $feature['dependencies'] ?? [] ) )
626 ->find( function ( $dependency ) {
627 return $dependency instanceof Non_Existing_Dependency;
628 } );
629
630 return (bool) $non_existing_dep;
631 }
632
633 /**
634 * Get Feature Settings Label HTML.
635 *
636 * @since 3.1.0
637 * @access private
638 *
639 * @param array $feature
640 *
641 * @return string
642 */
643 private function get_feature_settings_label_html( array $feature ) {
644 ob_start();
645
646 $is_feature_active = $this->is_feature_active( $feature['name'] );
647
648 $indicator_classes = 'e-experiment__title__indicator';
649
650 if ( $is_feature_active ) {
651 $indicator_classes .= ' e-experiment__title__indicator--active';
652 }
653
654 $indicator_tooltip = $this->get_feature_state_label( $feature );
655
656 ?>
657 <div class="e-experiment__title">
658 <div class="<?php echo $indicator_classes; ?>" data-tooltip="<?php echo $indicator_tooltip; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>"></div>
659 <label class="e-experiment__title__label" for="e-experiment-<?php echo $feature['name']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>"><?php echo $feature['title']; ?></label>
660 <?php foreach ( $feature['tags'] as $tag ) { ?>
661 <span class="e-experiment__title__tag e-experiment__title__tag__<?php echo $tag['type']; ?>"><?php echo $tag['label']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span>
662 <?php } ?>
663 <?php if ( $feature['deprecated'] ) { ?>
664 <span class="e-experiment__title__tag e-experiment__title__tag__deprecated"><?php echo esc_html__( 'Deprecated', 'elementor' ); ?></span>
665 <?php } ?>
666 </div>
667 <?php
668
669 return ob_get_clean();
670 }
671
672 /**
673 * Get Feature State Label
674 *
675 * @param array $feature
676 *
677 * @return string
678 */
679 public function get_feature_state_label( array $feature ) {
680 $is_feature_active = $this->is_feature_active( $feature['name'] );
681
682 if ( self::STATE_DEFAULT === $feature['state'] ) {
683 $label = $is_feature_active ? esc_html__( 'Active by default', 'elementor' ) :
684 esc_html__( 'Inactive by default', 'elementor' );
685 } else {
686 $label = self::STATE_ACTIVE === $feature['state'] ? esc_html__( 'Active', 'elementor' ) :
687 esc_html__( 'Inactive', 'elementor' );
688 }
689
690 return $label;
691 }
692
693 /**
694 * Get Feature Settings Label HTML
695 *
696 * @since 3.1.0
697 * @access private
698 *
699 * @param string $feature_name
700 *
701 * @return int
702 */
703 private function get_saved_feature_state( $feature_name ) {
704 return get_option( $this->get_feature_option_key( $feature_name ) );
705 }
706
707 /**
708 * Get Feature Actual State
709 *
710 * @since 3.1.0
711 * @access private
712 *
713 * @param array $feature
714 *
715 * @return string
716 */
717 private function get_feature_actual_state( array $feature ) {
718 if ( ! empty( $feature['state'] ) && self::STATE_DEFAULT !== $feature['state'] ) {
719 return $feature['state'];
720 }
721
722 return $feature['default'];
723 }
724
725 /**
726 * On Feature State Change
727 *
728 * @since 3.1.0
729 * @access private
730 *
731 * @param array $old_feature_data
732 * @param string $new_state
733 * @param string $old_state
734 *
735 * @throws Dependency_Exception If the feature dependency is not available or not active.
736 */
737 private function on_feature_state_change( array $old_feature_data, $new_state, $old_state ) {
738 $new_feature_data = $this->get_features( $old_feature_data['name'] );
739 $this->validate_dependency( $new_feature_data, $new_state );
740 $this->features[ $old_feature_data['name'] ]['state'] = $new_state;
741 if ( $old_state === $new_state ) {
742 return;
743 }
744
745 Plugin::$instance->files_manager->clear_cache();
746 if ( $new_feature_data['on_state_change'] ) {
747 $new_feature_data['on_state_change']( $old_state, $new_state );
748 }
749
750 do_action( 'elementor/experiments/feature-state-change/' . $old_feature_data['name'], $old_state, $new_state );
751 }
752
753 /**
754 * @throws Dependency_Exception If the feature dependency is not available or not active.
755 */
756 private function validate_dependency( array $feature, $new_state ) {
757 $rollback = function ( $feature_option_key, $state ) {
758 remove_all_actions( 'add_option_' . $feature_option_key );
759 remove_all_actions( 'update_option_' . $feature_option_key );
760
761 update_option( $feature_option_key, $state );
762 };
763
764 if ( self::STATE_DEFAULT === $new_state ) {
765 $new_state = $this->get_feature_actual_state( $feature );
766 }
767
768 $feature_option_key = $this->get_feature_option_key( $feature['name'] );
769
770 if ( self::STATE_ACTIVE === $new_state ) {
771 if ( empty( $feature['dependencies'] ) ) {
772 return;
773 }
774
775 // Validate if the current feature dependency is available.
776 foreach ( $feature['dependencies'] as $dependency ) {
777 $dependency_feature = $this->get_features( $dependency->get_name() );
778
779 if ( ! $dependency_feature ) {
780 $rollback( $feature_option_key, self::STATE_INACTIVE );
781
782 throw new Exceptions\Dependency_Exception(
783 sprintf(
784 'The feature `%s` has a dependency `%s` that is not available.',
785 esc_html( $feature['name'] ),
786 esc_html( $dependency->get_name() )
787 )
788 );
789 }
790
791 $dependency_state = $this->get_feature_actual_state( $dependency_feature );
792
793 // If dependency is not active.
794 if ( self::STATE_INACTIVE === $dependency_state ) {
795 $rollback( $feature_option_key, self::STATE_INACTIVE );
796
797 throw new Exceptions\Dependency_Exception(
798 sprintf(
799 'To turn on `%1$s`, Experiment: `%2$s` activity is required!',
800 esc_html( $feature['name'] ),
801 esc_html( $dependency_feature['name'] )
802 )
803 );
804 }
805 }
806 } elseif ( self::STATE_INACTIVE === $new_state ) {
807 // Make sure to deactivate a dependant experiment of the current feature when it's deactivated.
808 foreach ( $this->get_features() as $current_feature ) {
809 if ( empty( $current_feature['dependencies'] ) ) {
810 continue;
811 }
812
813 $current_feature_state = $this->get_feature_actual_state( $current_feature );
814
815 foreach ( $current_feature['dependencies'] as $dependency ) {
816 if ( self::STATE_ACTIVE === $current_feature_state && $feature['name'] === $dependency->get_name() ) {
817 update_option( $this->get_feature_option_key( $current_feature['name'] ), static::STATE_INACTIVE );
818 }
819 }
820 }
821 }
822 }
823
824 private function should_show_hidden() {
825 return defined( 'ELEMENTOR_SHOW_HIDDEN_EXPERIMENTS' ) && ELEMENTOR_SHOW_HIDDEN_EXPERIMENTS;
826 }
827
828 private function create_dependency_class( $dependency_name, $dependency_args ) {
829 if ( class_exists( $dependency_name ) ) {
830 return $dependency_name::instance();
831 }
832
833 if ( ! empty( $dependency_args ) ) {
834 return new Wrap_Core_Dependency( $dependency_args );
835 }
836
837 return new Non_Existing_Dependency( $dependency_name );
838 }
839
840 /**
841 * The experiments page is a WordPress options page, which means all the experiments are registered via WordPress' register_settings(),
842 * and their states are being sent in the POST request when saving.
843 * The options are being updated in a chronological order based on the POST data.
844 * This behavior interferes with the experiments dependency mechanism because the data that's being sent can be in any order,
845 * while the dependencies mechanism expects it to be in a specific order (dependencies should be activated before their dependents can).
846 * In order to solve this issue, we sort the experiments in the POST data based on their dependencies tree.
847 *
848 * @param array $allowed_options
849 */
850 private function sort_allowed_options_by_dependencies( $allowed_options ) {
851 if ( ! isset( $allowed_options['elementor'] ) ) {
852 return $allowed_options;
853 }
854
855 $sorted = Collection::make();
856 $visited = Collection::make();
857
858 $sort = function ( $item ) use ( &$sort, $sorted, $visited ) {
859 if ( $visited->contains( $item ) ) {
860 return;
861 }
862
863 $visited->push( $item );
864
865 $feature = $this->get_features( $item );
866
867 if ( ! $feature ) {
868 return;
869 }
870
871 foreach ( $feature['dependencies'] ?? [] as $dep ) {
872 $name = is_string( $dep ) ? $dep : $dep->get_name();
873
874 $sort( $name );
875 }
876
877 $sorted->push( $item );
878 };
879
880 foreach ( $allowed_options['elementor'] as $option ) {
881 $is_experiment_option = strpos( $option, static::OPTION_PREFIX ) === 0;
882
883 if ( ! $is_experiment_option ) {
884 continue;
885 }
886
887 $sort(
888 str_replace( static::OPTION_PREFIX, '', $option )
889 );
890 }
891
892 $allowed_options['elementor'] = Collection::make( $allowed_options['elementor'] )
893 ->filter( function ( $option ) {
894 return 0 !== strpos( $option, static::OPTION_PREFIX );
895 } )
896 ->merge(
897 $sorted->map( function ( $item ) {
898 return static::OPTION_PREFIX . $item;
899 } )
900 )
901 ->values();
902
903 return $allowed_options;
904 }
905
906 public function __construct() {
907 $this->init_states();
908
909 $this->init_release_statuses();
910
911 $this->init_features();
912
913 add_action( 'admin_init', function () {
914 System_Info::add_report(
915 'experiments', [
916 'file_name' => __DIR__ . '/experiments-reporter.php',
917 'class_name' => __NAMESPACE__ . '\Experiments_Reporter',
918 ]
919 );
920 }, 79 /* Before log */ );
921
922 if ( is_admin() ) {
923 $page_id = Settings::PAGE_ID;
924
925 add_action( "elementor/admin/after_create_settings/{$page_id}", function( Settings $settings ) {
926 $this->register_settings_fields( $settings );
927 }, 11 );
928
929 add_filter( 'allowed_options', function ( $allowed_options ) {
930 return $this->sort_allowed_options_by_dependencies( $allowed_options );
931 }, 11 );
932 }
933
934 // Register CLI commands.
935 if ( Utils::is_wp_cli() ) {
936 \WP_CLI::add_command( 'elementor experiments', WP_CLI::class );
937 }
938 }
939
940 /**
941 * @param array $experimental_data
942 * @return array
943 *
944 * @throws Dependency_Exception If the feature dependency is not initialized or depends on a hidden experiment.
945 */
946 private function initialize_feature_dependencies( array $experimental_data ): array {
947 foreach ( $experimental_data['dependencies'] as $key => $dependency ) {
948 $feature = $this->get_features( $dependency );
949
950 if ( ! isset( $feature ) ) {
951 // since we must validate the state of each dependency, we have to make sure that dependencies are initialized in the correct order, otherwise, error.
952 throw new Exceptions\Dependency_Exception(
953 sprintf(
954 'Feature %s cannot be initialized before dependency feature: %s.',
955 esc_html( $experimental_data['name'] ),
956 esc_html( $dependency )
957 )
958 );
959 }
960
961 if ( ! empty( $feature[ static::TYPE_HIDDEN ] ) ) {
962 throw new Exceptions\Dependency_Exception( 'Depending on a hidden experiment is not allowed.' );
963 }
964
965 $experimental_data['dependencies'][ $key ] = $this->create_dependency_class( $dependency, $feature );
966 $experimental_data = $this->set_feature_default_state_to_match_dependencies( $feature, $experimental_data );
967 }
968
969 return $experimental_data;
970 }
971
972 /**
973 * @param array $feature
974 * @param array $experimental_data
975 * @return array
976 *
977 * we must validate the state:
978 * * A user can set a dependant feature to inactive and in upgrade we don't change users settings.
979 * * A developer can set the default state to be invalid (e.g. dependant feature is inactive).
980 * if one of the dependencies is inactive, the main feature should be inactive as well.
981 */
982 private function set_feature_default_state_to_match_dependencies( array $feature, array $experimental_data ): array {
983 if ( self::STATE_INACTIVE !== $this->get_feature_actual_state( $feature ) ) {
984 return $experimental_data;
985 }
986
987 if ( self::STATE_ACTIVE === $experimental_data['state'] ) {
988 $experimental_data['state'] = self::STATE_INACTIVE;
989 } elseif ( self::STATE_DEFAULT === $experimental_data['state'] ) {
990 $experimental_data['default'] = self::STATE_INACTIVE;
991 }
992
993 return $experimental_data;
994 }
995
996 /**
997 * @param array $new_site
998 * @param array $experimental_data
999 * @return array
1000 */
1001 private function set_new_site_default_state( $new_site, array $experimental_data ): array {
1002 if ( ! $this->install_compare( $new_site['minimum_installation_version'] ) ) {
1003 return $experimental_data;
1004 }
1005
1006 if ( $new_site['always_active'] ) {
1007 $experimental_data['state'] = self::STATE_ACTIVE;
1008 $experimental_data['mutable'] = false;
1009 } elseif ( $new_site['default_active'] ) {
1010 $experimental_data['default'] = self::STATE_ACTIVE;
1011 } elseif ( $new_site['default_inactive'] ) {
1012 $experimental_data['default'] = self::STATE_INACTIVE;
1013 }
1014
1015 return $experimental_data;
1016 }
1017
1018 /**
1019 * @param array $options
1020 * @return array
1021 */
1022 private function set_feature_initial_options( array $options ): array {
1023 $default_experimental_data = [
1024 'tag' => '', // Deprecated, use 'tags' instead.
1025 'tags' => [],
1026 'description' => '',
1027 'release_status' => self::RELEASE_STATUS_ALPHA,
1028 'default' => self::STATE_INACTIVE,
1029 'mutable' => true,
1030 static::TYPE_HIDDEN => false,
1031 'new_site' => [
1032 'always_active' => false,
1033 'default_active' => false,
1034 'default_inactive' => false,
1035 'minimum_installation_version' => null,
1036 ],
1037 'on_state_change' => null,
1038 'generator_tag' => false,
1039 'deprecated' => false,
1040 ];
1041
1042 $allowed_options = [ 'name', 'title', 'tag', 'tags', 'description', 'release_status', 'default', 'mutable', static::TYPE_HIDDEN, 'new_site', 'on_state_change', 'dependencies', 'generator_tag', 'messages', 'deprecated' ];
1043 $experimental_data = $this->merge_properties( $default_experimental_data, $options, $allowed_options );
1044
1045 return $this->unify_feature_tags( $experimental_data );
1046 }
1047 }
1048