PluginProbe
Elementor Website Builder – more than just a page builder / 4.3.2
Elementor Website Builder – more than just a page builder v4.3.2
4.3.2 4.3.1 4.3.0 4.3.0-beta3 4.3.0-beta2 4.3.0-beta1 4.2.4 4.2.3 4.2.2 4.2.1 4.2.0 4.1.5 4.2.0-beta2 4.2.0-dev2 4.2.0-beta1 4.1.4 4.1.3 4.1.2 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 All 455 releases
elementor / core / experiments / manager.php

manager.php in Elementor Website Builder – more than just a page builder 4.3.2, at core/experiments/manager.php

1,109 lines 32.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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 // Fresh installation: upgrade manager hasn't written history yet on this first request.
125 // Use the current plugin version as the effective first-install version.
126 return version_compare( ELEMENTOR_VERSION, $version, '>=' );
127 }
128
129 $cleaned_version = preg_replace( '/-(beta|cloud|dev)\d*$/', '', key( $installs_history ) );
130
131 return version_compare(
132 $cleaned_version,
133 $version,
134 '>='
135 );
136 }
137
138 /**
139 * Combine 'tag' and 'tags' into one property.
140 *
141 * @param array $experimental_data
142 *
143 * @return array
144 */
145 private function unify_feature_tags( array $experimental_data ): array {
146 foreach ( [ 'tag', 'tags' ] as $key ) {
147 if ( empty( $experimental_data[ $key ] ) ) {
148 continue;
149 }
150
151 $experimental_data[ $key ] = $this->format_feature_tags( $experimental_data[ $key ] );
152 }
153
154 if ( is_array( $experimental_data['tag'] ) ) {
155 $experimental_data['tags'] = array_merge( $experimental_data['tag'], $experimental_data['tags'] );
156 }
157
158 return $experimental_data;
159 }
160
161 /**
162 * Format feature tags into the right format.
163 *
164 * If an array of tags provided, each tag has to provide the following information:
165 * [
166 * [
167 * 'type' => string,
168 * 'label' => string,
169 * ]
170 * ]
171 *
172 * @param string|array $tags A string of comma separated tags, or an array of tags.
173 *
174 * @return array
175 */
176 private function format_feature_tags( $tags ): array {
177 if ( ! is_string( $tags ) && ! is_array( $tags ) ) {
178 return [];
179 }
180
181 $default_tag = [
182 'type' => 'default',
183 'label' => '',
184 ];
185
186 $allowed_tag_properties = [ 'type', 'label' ];
187
188 // If $tags is string, explode by commas and convert to array.
189 if ( is_string( $tags ) ) {
190 $tags = array_filter( explode( ',', $tags ) );
191
192 foreach ( $tags as $i => $tag ) {
193 $tags[ $i ] = [ 'label' => trim( $tag ) ];
194 }
195 }
196
197 foreach ( $tags as $i => $tag ) {
198 if ( empty( $tag['label'] ) ) {
199 unset( $tags[ $i ] );
200 continue;
201 }
202
203 $tags[ $i ] = $this->merge_properties( $default_tag, $tag, $allowed_tag_properties );
204 }
205
206 return $tags;
207 }
208
209 /**
210 * Remove Feature
211 *
212 * @since 3.1.0
213 * @access public
214 *
215 * @param string $feature_name
216 */
217 public function remove_feature( $feature_name ) {
218 unset( $this->features[ $feature_name ] );
219 }
220
221 /**
222 * Get Features
223 *
224 * @since 3.1.0
225 * @access public
226 *
227 * @param string $feature_name Optional. Default is null.
228 *
229 * @return array|null
230 */
231 public function get_features( $feature_name = null ) {
232 return self::get_items( $this->features, $feature_name );
233 }
234
235 /**
236 * Get Active Features
237 *
238 * @since 3.1.0
239 * @access public
240 *
241 * @return array
242 */
243 public function get_active_features() {
244 return array_filter( $this->features, [ $this, 'is_feature_active' ], ARRAY_FILTER_USE_KEY );
245 }
246
247 /**
248 * Is Feature Active
249 *
250 * @since 3.1.0
251 * @access public
252 *
253 * @param string $feature_name Experiment feature name.
254 * @param bool $check_dependencies When true, also require dependency experiments to be active.
255 * Missing or hidden dependencies are treated as active for compatibility.
256 *
257 * @return bool
258 */
259 public function is_feature_active( $feature_name, $check_dependencies = false ) {
260 $feature = $this->get_features( $feature_name );
261
262 if ( ! $feature || self::STATE_ACTIVE !== $this->get_feature_actual_state( $feature ) ) {
263 return false;
264 }
265
266 if ( $check_dependencies && isset( $feature['dependencies'] ) && is_array( $feature['dependencies'] ) ) {
267 foreach ( $feature['dependencies'] as $dependency ) {
268 if ( $dependency instanceof Non_Existing_Dependency ) {
269 continue;
270 }
271
272 $dependent_feature = $this->get_features( $dependency->get_name() );
273
274 if ( $this->is_removed_or_hidden_dependency( $dependent_feature ) ) {
275 continue;
276 }
277
278 $feature_state = self::STATE_ACTIVE === $this->get_feature_actual_state( $dependent_feature );
279
280 if ( ! $feature_state ) {
281 return false;
282 }
283 }
284 }
285
286 return true;
287 }
288
289 /**
290 * Set Feature Default State
291 *
292 * @since 3.1.0
293 * @access public
294 *
295 * @param string $feature_name
296 * @param string $default_state
297 */
298 public function set_feature_default_state( $feature_name, $default_state ) {
299 $feature = $this->get_features( $feature_name );
300
301 if ( ! $feature ) {
302 return;
303 }
304
305 $this->features[ $feature_name ]['default'] = $default_state;
306 }
307
308 /**
309 * Get Feature Option Key
310 *
311 * @since 3.1.0
312 * @access public
313 *
314 * @param string $feature_name
315 *
316 * @return string
317 */
318 public function get_feature_option_key( $feature_name ) {
319 return static::OPTION_PREFIX . $feature_name;
320 }
321
322 private function add_default_features() {
323 $this->add_feature( [
324 'name' => 'e_font_icon_svg',
325 'title' => esc_html__( 'Inline Font Icons', 'elementor' ),
326 'tag' => esc_html__( 'Performance', 'elementor' ),
327 'description' => sprintf(
328 '%1$s <a href="https://go.elementor.com/wp-dash-inline-font-awesome/" target="_blank">%2$s</a>',
329 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' ),
330 esc_html__( 'Learn more', 'elementor' )
331 ),
332 'release_status' => self::RELEASE_STATUS_STABLE,
333 'new_site' => [
334 'default_active' => true,
335 'minimum_installation_version' => '3.17.0',
336 ],
337 'generator_tag' => true,
338 ] );
339
340 $this->add_feature( [
341 'name' => 'additional_custom_breakpoints',
342 'title' => esc_html__( 'Additional Custom Breakpoints', 'elementor' ),
343 'description' => sprintf(
344 '%1$s <a href="https://go.elementor.com/wp-dash-additional-custom-breakpoints/" target="_blank">%2$s</a>',
345 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' ),
346 esc_html__( 'Learn more', 'elementor' )
347 ),
348 'release_status' => self::RELEASE_STATUS_STABLE,
349 'default' => self::STATE_ACTIVE,
350 'generator_tag' => true,
351 ] );
352
353 $this->add_feature( [
354 'name' => 'container',
355 'title' => esc_html__( 'Container', 'elementor' ),
356 'description' => sprintf(
357 /* translators: 1: Link opening tag, 2: Link closing tag, 3: Link opening tag, 4: Link closing tag */
358 esc_html__( 'Create advanced layouts and responsive designs with %1$sFlexbox%2$s and %3$sGrid%4$s container elements.', 'elementor' ),
359 '<a target="_blank" href="https://go.elementor.com/wp-dash-flex-container/">',
360 '</a>',
361 '<a target="_blank" href="https://go.elementor.com/wp-dash-grid-container/">',
362 '</a>'
363 ),
364 'release_status' => self::RELEASE_STATUS_STABLE,
365 'default' => self::STATE_INACTIVE,
366 'new_site' => [
367 'default_active' => true,
368 'minimum_installation_version' => '3.16.0',
369 ],
370 'messages' => [
371 'on_deactivate' => sprintf(
372 '%1$s <a target="_blank" href="https://go.elementor.com/wp-dash-deactivate-container/">%2$s</a>',
373 esc_html__( 'Container-based content will be hidden from your site and may not be recoverable in all cases.', 'elementor' ),
374 esc_html__( 'Learn more', 'elementor' ),
375 ),
376 ],
377 ] );
378
379 $this->add_feature( [
380 'name' => 'e_optimized_markup',
381 'title' => esc_html__( 'Optimized Markup', 'elementor' ),
382 'tag' => esc_html__( 'Performance', 'elementor' ),
383 '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' ),
384 'release_status' => self::RELEASE_STATUS_STABLE,
385 'default' => self::STATE_INACTIVE,
386 'new_site' => [
387 'default_active' => true,
388 'minimum_installation_version' => '3.30.0',
389 ],
390 ] );
391
392 $this->add_feature( [
393 'name' => 'e_optimized_css_files',
394 'title' => esc_html__( 'Optimized CSS Files', 'elementor' ),
395 'tag' => esc_html__( 'Performance', 'elementor' ),
396 'description' => esc_html__( 'Keeps external CSS files available and consistent for sites behind page caching or a CDN.', 'elementor' ),
397 'release_status' => self::RELEASE_STATUS_ALPHA,
398 'default' => self::STATE_INACTIVE,
399 'generator_tag' => true,
400 ] );
401 }
402
403 /**
404 * Init States
405 *
406 * @since 3.1.0
407 * @access private
408 */
409 private function init_states() {
410 $this->states = [
411 self::STATE_DEFAULT => esc_html__( 'Default', 'elementor' ),
412 self::STATE_ACTIVE => esc_html__( 'Active', 'elementor' ),
413 self::STATE_INACTIVE => esc_html__( 'Inactive', 'elementor' ),
414 ];
415 }
416
417 /**
418 * Init Statuses
419 *
420 * @since 3.1.0
421 * @access private
422 */
423 private function init_release_statuses() {
424 $this->release_statuses = [
425 self::RELEASE_STATUS_DEV => esc_html__( 'Development', 'elementor' ),
426 self::RELEASE_STATUS_ALPHA => esc_html__( 'Alpha', 'elementor' ),
427 self::RELEASE_STATUS_BETA => esc_html__( 'Beta', 'elementor' ),
428 self::RELEASE_STATUS_STABLE => esc_html__( 'Stable', 'elementor' ),
429 ];
430 }
431
432 /**
433 * Init Features
434 *
435 * @since 3.1.0
436 * @access private
437 */
438 private function init_features() {
439 $this->features = [];
440
441 $this->add_default_features();
442
443 do_action( 'elementor/experiments/default-features-registered', $this );
444 }
445
446 /**
447 * Register Settings Fields
448 *
449 * @param Settings $settings
450 *
451 * @since 3.1.0
452 * @access private
453 */
454 private function register_settings_fields( Settings $settings ) {
455 $features = $this->get_features();
456
457 $fields = [];
458
459 foreach ( $features as $feature_name => $feature ) {
460 $is_hidden = $feature[ static::TYPE_HIDDEN ];
461 $is_mutable = $feature['mutable'];
462 $should_hide_experiment = ! $is_mutable || ( $is_hidden && ! $this->should_show_hidden() ) || $this->has_non_existing_dependency( $feature );
463
464 if ( $should_hide_experiment ) {
465 unset( $features[ $feature_name ] );
466
467 continue;
468 }
469
470 $feature_key = 'experiment-' . $feature_name;
471
472 $section = 'stable' === $feature['release_status'] ? 'stable' : 'ongoing';
473
474 $fields[ $section ][ $feature_key ]['label'] = $this->get_feature_settings_label_html( $feature );
475
476 $fields[ $section ][ $feature_key ]['field_args'] = $feature;
477
478 $fields[ $section ][ $feature_key ]['render'] = function( $feature ) {
479 $this->render_feature_settings_field( $feature );
480 };
481 }
482
483 foreach ( [ 'stable', 'ongoing' ] as $section ) {
484 if ( ! isset( $fields[ $section ] ) ) {
485 $fields[ $section ]['no_features'] = [
486 'label' => esc_html__( 'No available experiments', 'elementor' ),
487 'field_args' => [
488 'type' => 'raw_html',
489 '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' ),
490 ],
491 ];
492 }
493
494 if ( ! Tracker::is_allow_track() && 'stable' === $section ) {
495 $fields[ $section ] += $settings->get_usage_fields();
496 }
497 }
498
499 $settings->add_tab(
500 'experiments', [
501 'label' => esc_html__( 'Features', 'elementor' ),
502 'sections' => [
503 'ongoing_experiments' => [
504 'callback' => function() {
505 $this->render_settings_intro();
506 },
507 'fields' => $fields['ongoing'],
508 ],
509 'stable_experiments' => [
510 'callback' => function() {
511 $this->render_stable_section_title();
512 },
513 'fields' => $fields['stable'],
514 ],
515 ],
516 ]
517 );
518 }
519
520 private function render_stable_section_title() {
521 ?>
522 <hr>
523 <h2>
524 <?php echo esc_html__( 'Stable Features', 'elementor' ); ?>
525 </h2>
526 <?php
527 }
528
529 /**
530 * Render Settings Intro
531 *
532 * @since 3.1.0
533 * @access private
534 */
535 private function render_settings_intro() {
536 ?>
537 <h2>
538 <?php echo esc_html__( 'Experiments and Features', 'elementor' ); ?>
539 </h2>
540 <p class="e-experiment__description">
541 <?php
542 printf(
543 /* translators: %1$s Link open tag, %2$s: Link close tag. */
544 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' ),
545 '<a href="https://go.elementor.com/wp-dash-experiments-report-an-issue/" target="_blank">',
546 '</a>'
547 );
548 ?>
549 </p>
550 <p class="e-experiment__description">
551 <?php
552 printf(
553 '%1$s <a href="https://go.elementor.com/wp-dash-experiments/" target="_blank">%2$s</a>',
554 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' ),
555 esc_html__( 'Learn more', 'elementor' ),
556 );
557 ?>
558 </p>
559
560 <?php if ( $this->get_features() ) { ?>
561 <button type="button" class="button e-experiment__button" value="active"><?php echo esc_html__( 'Activate All', 'elementor' ); ?></button>
562 <button type="button" class="button e-experiment__button" value="inactive"><?php echo esc_html__( 'Deactivate All', 'elementor' ); ?></button>
563 <button type="button" class="button e-experiment__button" value="default"><?php echo esc_html__( 'Back to default', 'elementor' ); ?></button>
564 <?php } ?>
565 <hr>
566 <h2 class="e-experiment__table-title">
567 <?php echo esc_html__( 'Ongoing Experiments', 'elementor' ); ?>
568 </h2>
569 <?php
570 }
571
572 /**
573 * Render Feature Settings Field
574 *
575 * @since 3.1.0
576 * @access private
577 *
578 * @param array $feature
579 */
580 private function render_feature_settings_field( array $feature ) {
581 $control_id = 'e-experiment-' . $feature['name'];
582 $control_name = $this->get_feature_option_key( $feature['name'] );
583
584 $status = sprintf(
585 /* translators: %s Release status. */
586 esc_html__( 'Status: %s', 'elementor' ),
587 $this->release_statuses[ $feature['release_status'] ]
588 );
589
590 ?>
591 <div class="e-experiment__content">
592 <select class="e-experiment__select"
593 id="<?php echo esc_attr( $control_id ); ?>"
594 name="<?php echo esc_attr( $control_name ); ?>"
595 data-experiment-id="<?php echo esc_attr( $feature['name'] ); ?>"
596 >
597 <?php foreach ( $this->states as $state_key => $state_title ) { ?>
598 <option value="<?php echo esc_attr( $state_key ); ?>"
599 <?php selected( $state_key, $feature['state'] ); ?>
600 >
601 <?php echo esc_html( $state_title ); ?>
602 </option>
603 <?php } ?>
604 </select>
605
606 <p class="description">
607 <?php Utils::print_unescaped_internal_string( $feature['description'] ); ?>
608 </p>
609
610 <?php $this->render_feature_dependency( $feature ); ?>
611
612 <?php if ( 'stable' !== $feature['release_status'] ) { ?>
613 <div class="e-experiment__status">
614 <?php echo esc_html( $status ); ?>
615 </div>
616 <?php } ?>
617 </div>
618 <?php
619 }
620
621 private function render_feature_dependency( $feature ) {
622 $dependencies = ( new Collection( $feature['dependencies'] ?? [] ) )
623 ->map( function ( $dependency ) {
624 return $dependency->get_title();
625 } )
626 ->implode( ', ' );
627
628 if ( empty( $dependencies ) ) {
629 return;
630 }
631
632 ?>
633 <div class="e-experiment__dependency">
634 <strong class="e-experiment__dependency__title"><?php echo esc_html__( 'Requires', 'elementor' ); ?>:</strong>
635 <span><?php echo esc_html( $dependencies ); ?></span>
636 </div>
637 <?php
638 }
639
640 private function has_non_existing_dependency( $feature ) {
641 $non_existing_dep = ( new Collection( $feature['dependencies'] ?? [] ) )
642 ->find( function ( $dependency ) {
643 return $dependency instanceof Non_Existing_Dependency;
644 } );
645
646 return (bool) $non_existing_dep;
647 }
648
649 /**
650 * Get Feature Settings Label HTML.
651 *
652 * @since 3.1.0
653 * @access private
654 *
655 * @param array $feature
656 *
657 * @return string
658 */
659 private function get_feature_settings_label_html( array $feature ) {
660 ob_start();
661
662 $is_feature_active = $this->is_feature_active( $feature['name'] );
663
664 $indicator_classes = 'e-experiment__title__indicator';
665
666 if ( $is_feature_active ) {
667 $indicator_classes .= ' e-experiment__title__indicator--active';
668 }
669
670 $indicator_tooltip = $this->get_feature_state_label( $feature );
671
672 ?>
673 <div class="e-experiment__title">
674 <div class="<?php echo $indicator_classes; ?>" data-tooltip="<?php echo $indicator_tooltip; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>"></div>
675 <label class="e-experiment__title__label" for="e-experiment-<?php echo $feature['name']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>"><?php echo $feature['title']; ?></label>
676 <?php foreach ( $feature['tags'] as $tag ) { ?>
677 <?php
678 $tag_classes = 'e-experiment__title__tag e-experiment__title__tag__' . $tag['type'] . ' e-editor-one';
679 ?>
680 <span class="<?php echo esc_attr( $tag_classes ); ?>"><?php echo $tag['label']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span>
681 <?php } ?>
682 <?php if ( $feature['deprecated'] ) { ?>
683 <span class="e-experiment__title__tag e-experiment__title__tag__deprecated"><?php echo esc_html__( 'Deprecated', 'elementor' ); ?></span>
684 <?php } ?>
685 </div>
686 <?php
687
688 return ob_get_clean();
689 }
690
691 /**
692 * Get Feature State Label
693 *
694 * @param array $feature
695 *
696 * @return string
697 */
698 public function get_feature_state_label( array $feature ) {
699 $is_feature_active = $this->is_feature_active( $feature['name'] );
700
701 if ( self::STATE_DEFAULT === $feature['state'] ) {
702 $label = $is_feature_active ? esc_html__( 'Active by default', 'elementor' ) :
703 esc_html__( 'Inactive by default', 'elementor' );
704 } else {
705 $label = self::STATE_ACTIVE === $feature['state'] ? esc_html__( 'Active', 'elementor' ) :
706 esc_html__( 'Inactive', 'elementor' );
707 }
708
709 return $label;
710 }
711
712 /**
713 * Get Feature Settings Label HTML
714 *
715 * @since 3.1.0
716 * @access private
717 *
718 * @param string $feature_name
719 *
720 * @return int
721 */
722 private function get_saved_feature_state( $feature_name ) {
723 return get_option( $this->get_feature_option_key( $feature_name ) );
724 }
725
726 /**
727 * Get Feature Actual State
728 *
729 * @since 3.1.0
730 * @access private
731 *
732 * @param array $feature
733 *
734 * @return string
735 */
736 private function get_feature_actual_state( array $feature ) {
737 if ( ! empty( $feature['state'] ) && self::STATE_DEFAULT !== $feature['state'] ) {
738 return $feature['state'];
739 }
740
741 return $feature['default'];
742 }
743
744 /**
745 * On Feature State Change
746 *
747 * @since 3.1.0
748 * @access private
749 *
750 * @param array $old_feature_data
751 * @param string $new_state
752 * @param string $old_state
753 */
754 private function on_feature_state_change( array $old_feature_data, $new_state, $old_state ) {
755 $new_feature_data = $this->get_features( $old_feature_data['name'] );
756 $this->validate_dependency( $new_feature_data, $new_state );
757 $this->features[ $old_feature_data['name'] ]['state'] = $new_state;
758 if ( $old_state === $new_state ) {
759 return;
760 }
761
762 Plugin::$instance->files_manager->clear_cache();
763 if ( $new_feature_data['on_state_change'] ) {
764 $new_feature_data['on_state_change']( $old_state, $new_state );
765 }
766
767 do_action( 'elementor/experiments/feature-state-change/' . $old_feature_data['name'], $old_state, $new_state );
768 }
769
770 /**
771 * @throws Exceptions\Dependency_Exception If the feature dependency is not available or not active.
772 */
773 private function validate_dependency( array $feature, $new_state ) {
774 $rollback = function ( $feature_option_key, $state ) {
775 remove_all_actions( 'add_option_' . $feature_option_key );
776 remove_all_actions( 'update_option_' . $feature_option_key );
777
778 update_option( $feature_option_key, $state );
779 };
780
781 if ( self::STATE_DEFAULT === $new_state ) {
782 $new_state = $this->get_feature_actual_state( $feature );
783 }
784
785 $feature_option_key = $this->get_feature_option_key( $feature['name'] );
786
787 if ( self::STATE_ACTIVE === $new_state ) {
788 if ( empty( $feature['dependencies'] ) ) {
789 return;
790 }
791
792 // Validate if the current feature dependency is available.
793 foreach ( $feature['dependencies'] as $dependency ) {
794 if ( $dependency instanceof Non_Existing_Dependency ) {
795 $this->warn_removed_or_hidden_dependency(
796 sprintf(
797 'The feature `%s` has a dependency `%s` that is not available in Core.',
798 esc_html( $feature['name'] ),
799 esc_html( $dependency->get_name() )
800 )
801 );
802 continue;
803 }
804
805 $dependency_feature = $this->get_features( $dependency->get_name() );
806
807 if ( ! $dependency_feature ) {
808 $this->warn_removed_or_hidden_dependency(
809 sprintf(
810 'The feature `%s` has a dependency `%s` that is not available in Core.',
811 esc_html( $feature['name'] ),
812 esc_html( $dependency->get_name() )
813 )
814 );
815 continue;
816 }
817
818 if ( $this->is_removed_or_hidden_dependency( $dependency_feature ) ) {
819 $this->warn_removed_or_hidden_dependency(
820 sprintf(
821 'The feature `%1$s` depends on hidden experiment `%2$s`.',
822 esc_html( $feature['name'] ),
823 esc_html( $dependency_feature['name'] )
824 )
825 );
826 continue;
827 }
828
829 $dependency_state = $this->get_feature_actual_state( $dependency_feature );
830
831 // If dependency is not active.
832 if ( self::STATE_INACTIVE === $dependency_state ) {
833 $rollback( $feature_option_key, self::STATE_INACTIVE );
834
835 throw new Exceptions\Dependency_Exception(
836 sprintf(
837 'To turn on `%1$s`, Experiment: `%2$s` activity is required!',
838 esc_html( $feature['name'] ),
839 esc_html( $dependency_feature['name'] )
840 )
841 );
842 }
843 }
844 } elseif ( self::STATE_INACTIVE === $new_state ) {
845 // Make sure to deactivate a dependant experiment of the current feature when it's deactivated.
846 foreach ( $this->get_features() as $current_feature ) {
847 if ( empty( $current_feature['dependencies'] ) ) {
848 continue;
849 }
850
851 $current_feature_state = $this->get_feature_actual_state( $current_feature );
852
853 foreach ( $current_feature['dependencies'] as $dependency ) {
854 if ( self::STATE_ACTIVE === $current_feature_state && $feature['name'] === $dependency->get_name() ) {
855 update_option( $this->get_feature_option_key( $current_feature['name'] ), static::STATE_INACTIVE );
856 }
857 }
858 }
859 }
860 }
861
862 private function should_show_hidden() {
863 return defined( 'ELEMENTOR_SHOW_HIDDEN_EXPERIMENTS' ) && ELEMENTOR_SHOW_HIDDEN_EXPERIMENTS;
864 }
865
866 private function create_dependency_class( $dependency_name, $dependency_args ) {
867 if ( class_exists( $dependency_name ) ) {
868 return $dependency_name::instance();
869 }
870
871 if ( ! empty( $dependency_args ) ) {
872 return new Wrap_Core_Dependency( $dependency_args );
873 }
874
875 return new Non_Existing_Dependency( $dependency_name );
876 }
877
878 /**
879 * The experiments page is a WordPress options page, which means all the experiments are registered via WordPress' register_settings(),
880 * and their states are being sent in the POST request when saving.
881 * The options are being updated in a chronological order based on the POST data.
882 * This behavior interferes with the experiments dependency mechanism because the data that's being sent can be in any order,
883 * while the dependencies mechanism expects it to be in a specific order (dependencies should be activated before their dependents can).
884 * In order to solve this issue, we sort the experiments in the POST data based on their dependencies tree.
885 *
886 * @param array $allowed_options
887 */
888 private function sort_allowed_options_by_dependencies( $allowed_options ) {
889 if ( ! isset( $allowed_options['elementor'] ) ) {
890 return $allowed_options;
891 }
892
893 $sorted = Collection::make();
894 $visited = Collection::make();
895
896 $sort = function ( $item ) use ( &$sort, $sorted, $visited ) {
897 if ( $visited->contains( $item ) ) {
898 return;
899 }
900
901 $visited->push( $item );
902
903 $feature = $this->get_features( $item );
904
905 if ( ! $feature ) {
906 return;
907 }
908
909 foreach ( $feature['dependencies'] ?? [] as $dep ) {
910 $name = is_string( $dep ) ? $dep : $dep->get_name();
911
912 $sort( $name );
913 }
914
915 $sorted->push( $item );
916 };
917
918 foreach ( $allowed_options['elementor'] as $option ) {
919 $is_experiment_option = strpos( $option, static::OPTION_PREFIX ) === 0;
920
921 if ( ! $is_experiment_option ) {
922 continue;
923 }
924
925 $sort(
926 str_replace( static::OPTION_PREFIX, '', $option )
927 );
928 }
929
930 $allowed_options['elementor'] = Collection::make( $allowed_options['elementor'] )
931 ->filter( function ( $option ) {
932 return 0 !== strpos( $option, static::OPTION_PREFIX );
933 } )
934 ->merge(
935 $sorted->map( function ( $item ) {
936 return static::OPTION_PREFIX . $item;
937 } )
938 )
939 ->values();
940
941 return $allowed_options;
942 }
943
944 public function __construct() {
945 $this->init_states();
946
947 $this->init_release_statuses();
948
949 $this->init_features();
950
951 add_action( 'admin_init', function () {
952 System_Info::add_report(
953 'experiments', [
954 'file_name' => __DIR__ . '/experiments-reporter.php',
955 'class_name' => __NAMESPACE__ . '\Experiments_Reporter',
956 ]
957 );
958 }, 79 /* Before log */ );
959
960 if ( is_admin() ) {
961 $page_id = Settings::PAGE_ID;
962
963 add_action( "elementor/admin/after_create_settings/{$page_id}", function( Settings $settings ) {
964 $this->register_settings_fields( $settings );
965 }, 11 );
966
967 add_filter( 'allowed_options', function ( $allowed_options ) {
968 return $this->sort_allowed_options_by_dependencies( $allowed_options );
969 }, 11 );
970 }
971
972 // Register CLI commands.
973 if ( Utils::is_wp_cli() ) {
974 \WP_CLI::add_command( 'elementor experiments', WP_CLI::class );
975 }
976 }
977
978 /**
979 * @param array $experimental_data
980 * @return array
981 */
982 private function initialize_feature_dependencies( array $experimental_data ): array {
983 foreach ( $experimental_data['dependencies'] as $key => $dependency ) {
984 $feature = $this->get_features( $dependency );
985
986 if ( ! isset( $feature ) ) {
987 $this->warn_removed_or_hidden_dependency(
988 sprintf(
989 'Feature %1$s depends on experiment %2$s that is not registered in Core.',
990 esc_html( $experimental_data['name'] ),
991 esc_html( $dependency )
992 )
993 );
994
995 $experimental_data['dependencies'][ $key ] = $this->create_dependency_class( $dependency, null );
996 continue;
997 }
998
999 if ( ! empty( $feature[ static::TYPE_HIDDEN ] ) ) {
1000 $this->warn_removed_or_hidden_dependency(
1001 sprintf(
1002 'Feature %1$s depends on hidden experiment %2$s.',
1003 esc_html( $experimental_data['name'] ),
1004 esc_html( $dependency )
1005 )
1006 );
1007 }
1008
1009 $experimental_data['dependencies'][ $key ] = $this->create_dependency_class( $dependency, $feature );
1010 $experimental_data = $this->set_feature_default_state_to_match_dependencies( $feature, $experimental_data );
1011 }
1012
1013 return $experimental_data;
1014 }
1015
1016 private function is_removed_or_hidden_dependency( $dependency_feature ): bool {
1017 if ( ! $dependency_feature ) {
1018 return true;
1019 }
1020
1021 return ! empty( $dependency_feature[ static::TYPE_HIDDEN ] );
1022 }
1023
1024 private function warn_removed_or_hidden_dependency( string $message ): void {
1025 if ( ! defined( 'WP_DEBUG' ) || ! WP_DEBUG ) {
1026 return;
1027 }
1028
1029 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Developer notice; message is escaped.
1030 _doing_it_wrong( __METHOD__, esc_html( $message ), ELEMENTOR_VERSION );
1031 }
1032
1033 /**
1034 * @param array $feature
1035 * @param array $experimental_data
1036 * @return array
1037 *
1038 * we must validate the state:
1039 * * A user can set a dependant feature to inactive and in upgrade we don't change users settings.
1040 * * A developer can set the default state to be invalid (e.g. dependant feature is inactive).
1041 * if one of the dependencies is inactive, the main feature should be inactive as well.
1042 */
1043 private function set_feature_default_state_to_match_dependencies( array $feature, array $experimental_data ): array {
1044 if ( self::STATE_INACTIVE !== $this->get_feature_actual_state( $feature ) ) {
1045 return $experimental_data;
1046 }
1047
1048 if ( self::STATE_ACTIVE === $experimental_data['state'] ) {
1049 $experimental_data['state'] = self::STATE_INACTIVE;
1050 } elseif ( self::STATE_DEFAULT === $experimental_data['state'] ) {
1051 $experimental_data['default'] = self::STATE_INACTIVE;
1052 }
1053
1054 return $experimental_data;
1055 }
1056
1057 /**
1058 * @param array $new_site
1059 * @param array $experimental_data
1060 * @return array
1061 */
1062 private function set_new_site_default_state( $new_site, array $experimental_data ): array {
1063 if ( ! $this->install_compare( $new_site['minimum_installation_version'] ) ) {
1064 return $experimental_data;
1065 }
1066
1067 if ( $new_site['always_active'] ) {
1068 $experimental_data['state'] = self::STATE_ACTIVE;
1069 $experimental_data['mutable'] = false;
1070 } elseif ( $new_site['default_active'] ) {
1071 $experimental_data['default'] = self::STATE_ACTIVE;
1072 } elseif ( $new_site['default_inactive'] ) {
1073 $experimental_data['default'] = self::STATE_INACTIVE;
1074 }
1075
1076 return $experimental_data;
1077 }
1078
1079 /**
1080 * @param array $options
1081 * @return array
1082 */
1083 private function set_feature_initial_options( array $options ): array {
1084 $default_experimental_data = [
1085 'tag' => '', // Deprecated, use 'tags' instead.
1086 'tags' => [],
1087 'description' => '',
1088 'release_status' => self::RELEASE_STATUS_ALPHA,
1089 'default' => self::STATE_INACTIVE,
1090 'mutable' => true,
1091 static::TYPE_HIDDEN => false,
1092 'new_site' => [
1093 'always_active' => false,
1094 'default_active' => false,
1095 'default_inactive' => false,
1096 'minimum_installation_version' => null,
1097 ],
1098 'on_state_change' => null,
1099 'generator_tag' => false,
1100 'deprecated' => false,
1101 ];
1102
1103 $allowed_options = [ 'name', 'title', 'tag', 'tags', 'description', 'release_status', 'default', 'mutable', static::TYPE_HIDDEN, 'new_site', 'on_state_change', 'dependencies', 'generator_tag', 'messages', 'deprecated' ];
1104 $experimental_data = $this->merge_properties( $default_experimental_data, $options, $allowed_options );
1105
1106 return $this->unify_feature_tags( $experimental_data );
1107 }
1108 }
1109