PluginProbe
Elementor Website Builder – more than just a page builder / 3.30.0-dev3
Elementor Website Builder – more than just a page builder v3.30.0-dev3
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 4.0.8 4.1.0-beta1 4.1.0-dev1 4.0.7 All 451 releases
elementor / core / experiments / manager.php

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

1,042 lines 30.4 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 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 'new_site' => [
375 'default_active' => true,
376 'minimum_installation_version' => '3.30.0',
377 ],
378 ] );
379 }
380
381 /**
382 * Init States
383 *
384 * @since 3.1.0
385 * @access private
386 */
387 private function init_states() {
388 $this->states = [
389 self::STATE_DEFAULT => esc_html__( 'Default', 'elementor' ),
390 self::STATE_ACTIVE => esc_html__( 'Active', 'elementor' ),
391 self::STATE_INACTIVE => esc_html__( 'Inactive', 'elementor' ),
392 ];
393 }
394
395 /**
396 * Init Statuses
397 *
398 * @since 3.1.0
399 * @access private
400 */
401 private function init_release_statuses() {
402 $this->release_statuses = [
403 self::RELEASE_STATUS_DEV => esc_html__( 'Development', 'elementor' ),
404 self::RELEASE_STATUS_ALPHA => esc_html__( 'Alpha', 'elementor' ),
405 self::RELEASE_STATUS_BETA => esc_html__( 'Beta', 'elementor' ),
406 self::RELEASE_STATUS_STABLE => esc_html__( 'Stable', 'elementor' ),
407 ];
408 }
409
410 /**
411 * Init Features
412 *
413 * @since 3.1.0
414 * @access private
415 */
416 private function init_features() {
417 $this->features = [];
418
419 $this->add_default_features();
420
421 do_action( 'elementor/experiments/default-features-registered', $this );
422 }
423
424 /**
425 * Register Settings Fields
426 *
427 * @param Settings $settings
428 *
429 * @since 3.1.0
430 * @access private
431 */
432 private function register_settings_fields( Settings $settings ) {
433 $features = $this->get_features();
434
435 $fields = [];
436
437 foreach ( $features as $feature_name => $feature ) {
438 $is_hidden = $feature[ static::TYPE_HIDDEN ];
439 $is_mutable = $feature['mutable'];
440 $should_hide_experiment = ! $is_mutable || ( $is_hidden && ! $this->should_show_hidden() ) || $this->has_non_existing_dependency( $feature );
441
442 if ( $should_hide_experiment ) {
443 unset( $features[ $feature_name ] );
444
445 continue;
446 }
447
448 $feature_key = 'experiment-' . $feature_name;
449
450 $section = 'stable' === $feature['release_status'] ? 'stable' : 'ongoing';
451
452 $fields[ $section ][ $feature_key ]['label'] = $this->get_feature_settings_label_html( $feature );
453
454 $fields[ $section ][ $feature_key ]['field_args'] = $feature;
455
456 $fields[ $section ][ $feature_key ]['render'] = function( $feature ) {
457 $this->render_feature_settings_field( $feature );
458 };
459 }
460
461 foreach ( [ 'stable', 'ongoing' ] as $section ) {
462 if ( ! isset( $fields[ $section ] ) ) {
463 $fields[ $section ]['no_features'] = [
464 'label' => esc_html__( 'No available experiments', 'elementor' ),
465 'field_args' => [
466 'type' => 'raw_html',
467 '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' ),
468 ],
469 ];
470 }
471
472 if ( ! Tracker::is_allow_track() && 'stable' === $section ) {
473 $fields[ $section ] += $settings->get_usage_fields();
474 }
475 }
476
477 $settings->add_tab(
478 'experiments', [
479 'label' => esc_html__( 'Features', 'elementor' ),
480 'sections' => [
481 'ongoing_experiments' => [
482 'callback' => function() {
483 $this->render_settings_intro();
484 },
485 'fields' => $fields['ongoing'],
486 ],
487 'stable_experiments' => [
488 'callback' => function() {
489 $this->render_stable_section_title();
490 },
491 'fields' => $fields['stable'],
492 ],
493 ],
494 ]
495 );
496 }
497
498 private function render_stable_section_title() {
499 ?>
500 <hr>
501 <h2>
502 <?php echo esc_html__( 'Stable Features', 'elementor' ); ?>
503 </h2>
504 <?php
505 }
506
507 /**
508 * Render Settings Intro
509 *
510 * @since 3.1.0
511 * @access private
512 */
513 private function render_settings_intro() {
514 ?>
515 <h2>
516 <?php echo esc_html__( 'Experiments and Features', 'elementor' ); ?>
517 </h2>
518 <p class="e-experiment__description">
519 <?php
520 printf(
521 /* translators: %1$s Link open tag, %2$s: Link close tag. */
522 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' ),
523 '<a href="https://go.elementor.com/wp-dash-experiments-report-an-issue/" target="_blank">',
524 '</a>'
525 );
526 ?>
527 </p>
528 <p class="e-experiment__description">
529 <?php
530 printf(
531 '%1$s <a href="https://go.elementor.com/wp-dash-experiments/" target="_blank">%2$s</a>',
532 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' ),
533 esc_html__( 'Learn more', 'elementor' ),
534 );
535 ?>
536 </p>
537
538 <?php if ( $this->get_features() ) { ?>
539 <button type="button" class="button e-experiment__button" value="active"><?php echo esc_html__( 'Activate All', 'elementor' ); ?></button>
540 <button type="button" class="button e-experiment__button" value="inactive"><?php echo esc_html__( 'Deactivate All', 'elementor' ); ?></button>
541 <button type="button" class="button e-experiment__button" value="default"><?php echo esc_html__( 'Back to default', 'elementor' ); ?></button>
542 <?php } ?>
543 <hr>
544 <h2 class="e-experiment__table-title">
545 <?php echo esc_html__( 'Ongoing Experiments', 'elementor' ); ?>
546 </h2>
547 <?php
548 }
549
550 /**
551 * Render Feature Settings Field
552 *
553 * @since 3.1.0
554 * @access private
555 *
556 * @param array $feature
557 */
558 private function render_feature_settings_field( array $feature ) {
559 $control_id = 'e-experiment-' . $feature['name'];
560 $control_name = $this->get_feature_option_key( $feature['name'] );
561
562 $status = sprintf(
563 /* translators: %s Release status. */
564 esc_html__( 'Status: %s', 'elementor' ),
565 $this->release_statuses[ $feature['release_status'] ]
566 );
567
568 ?>
569 <div class="e-experiment__content">
570 <select class="e-experiment__select"
571 id="<?php echo esc_attr( $control_id ); ?>"
572 name="<?php echo esc_attr( $control_name ); ?>"
573 data-experiment-id="<?php echo esc_attr( $feature['name'] ); ?>"
574 >
575 <?php foreach ( $this->states as $state_key => $state_title ) { ?>
576 <option value="<?php echo esc_attr( $state_key ); ?>"
577 <?php selected( $state_key, $feature['state'] ); ?>
578 >
579 <?php echo esc_html( $state_title ); ?>
580 </option>
581 <?php } ?>
582 </select>
583
584 <p class="description">
585 <?php Utils::print_unescaped_internal_string( $feature['description'] ); ?>
586 </p>
587
588 <?php $this->render_feature_dependency( $feature ); ?>
589
590 <?php if ( 'stable' !== $feature['release_status'] ) { ?>
591 <div class="e-experiment__status">
592 <?php echo esc_html( $status ); ?>
593 </div>
594 <?php } ?>
595 </div>
596 <?php
597 }
598
599 private function render_feature_dependency( $feature ) {
600 $dependencies = ( new Collection( $feature['dependencies'] ?? [] ) )
601 ->map( function ( $dependency ) {
602 return $dependency->get_title();
603 } )
604 ->implode( ', ' );
605
606 if ( empty( $dependencies ) ) {
607 return;
608 }
609
610 ?>
611 <div class="e-experiment__dependency">
612 <strong class="e-experiment__dependency__title"><?php echo esc_html__( 'Requires', 'elementor' ); ?>:</strong>
613 <span><?php echo esc_html( $dependencies ); ?></span>
614 </div>
615 <?php
616 }
617
618 private function has_non_existing_dependency( $feature ) {
619 $non_existing_dep = ( new Collection( $feature['dependencies'] ?? [] ) )
620 ->find( function ( $dependency ) {
621 return $dependency instanceof Non_Existing_Dependency;
622 } );
623
624 return (bool) $non_existing_dep;
625 }
626
627 /**
628 * Get Feature Settings Label HTML.
629 *
630 * @since 3.1.0
631 * @access private
632 *
633 * @param array $feature
634 *
635 * @return string
636 */
637 private function get_feature_settings_label_html( array $feature ) {
638 ob_start();
639
640 $is_feature_active = $this->is_feature_active( $feature['name'] );
641
642 $indicator_classes = 'e-experiment__title__indicator';
643
644 if ( $is_feature_active ) {
645 $indicator_classes .= ' e-experiment__title__indicator--active';
646 }
647
648 $indicator_tooltip = $this->get_feature_state_label( $feature );
649
650 ?>
651 <div class="e-experiment__title">
652 <div class="<?php echo $indicator_classes; ?>" data-tooltip="<?php echo $indicator_tooltip; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>"></div>
653 <label class="e-experiment__title__label" for="e-experiment-<?php echo $feature['name']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>"><?php echo $feature['title']; ?></label>
654 <?php foreach ( $feature['tags'] as $tag ) { ?>
655 <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>
656 <?php } ?>
657 <?php if ( $feature['deprecated'] ) { ?>
658 <span class="e-experiment__title__tag e-experiment__title__tag__deprecated"><?php echo esc_html__( 'Deprecated', 'elementor' ); ?></span>
659 <?php } ?>
660 </div>
661 <?php
662
663 return ob_get_clean();
664 }
665
666 /**
667 * Get Feature State Label
668 *
669 * @param array $feature
670 *
671 * @return string
672 */
673 public function get_feature_state_label( array $feature ) {
674 $is_feature_active = $this->is_feature_active( $feature['name'] );
675
676 if ( self::STATE_DEFAULT === $feature['state'] ) {
677 $label = $is_feature_active ? esc_html__( 'Active by default', 'elementor' ) :
678 esc_html__( 'Inactive by default', 'elementor' );
679 } else {
680 $label = self::STATE_ACTIVE === $feature['state'] ? esc_html__( 'Active', 'elementor' ) :
681 esc_html__( 'Inactive', 'elementor' );
682 }
683
684 return $label;
685 }
686
687 /**
688 * Get Feature Settings Label HTML
689 *
690 * @since 3.1.0
691 * @access private
692 *
693 * @param string $feature_name
694 *
695 * @return int
696 */
697 private function get_saved_feature_state( $feature_name ) {
698 return get_option( $this->get_feature_option_key( $feature_name ) );
699 }
700
701 /**
702 * Get Feature Actual State
703 *
704 * @since 3.1.0
705 * @access private
706 *
707 * @param array $feature
708 *
709 * @return string
710 */
711 private function get_feature_actual_state( array $feature ) {
712 if ( ! empty( $feature['state'] ) && self::STATE_DEFAULT !== $feature['state'] ) {
713 return $feature['state'];
714 }
715
716 return $feature['default'];
717 }
718
719 /**
720 * On Feature State Change
721 *
722 * @since 3.1.0
723 * @access private
724 *
725 * @param array $old_feature_data
726 * @param string $new_state
727 * @param string $old_state
728 *
729 * @throws Dependency_Exception If the feature dependency is not available or not active.
730 */
731 private function on_feature_state_change( array $old_feature_data, $new_state, $old_state ) {
732 $new_feature_data = $this->get_features( $old_feature_data['name'] );
733 $this->validate_dependency( $new_feature_data, $new_state );
734 $this->features[ $old_feature_data['name'] ]['state'] = $new_state;
735 if ( $old_state === $new_state ) {
736 return;
737 }
738
739 Plugin::$instance->files_manager->clear_cache();
740 if ( $new_feature_data['on_state_change'] ) {
741 $new_feature_data['on_state_change']( $old_state, $new_state );
742 }
743
744 do_action( 'elementor/experiments/feature-state-change/' . $old_feature_data['name'], $old_state, $new_state );
745 }
746
747 /**
748 * @throws Dependency_Exception If the feature dependency is not available or not active.
749 */
750 private function validate_dependency( array $feature, $new_state ) {
751 $rollback = function ( $feature_option_key, $state ) {
752 remove_all_actions( 'add_option_' . $feature_option_key );
753 remove_all_actions( 'update_option_' . $feature_option_key );
754
755 update_option( $feature_option_key, $state );
756 };
757
758 if ( self::STATE_DEFAULT === $new_state ) {
759 $new_state = $this->get_feature_actual_state( $feature );
760 }
761
762 $feature_option_key = $this->get_feature_option_key( $feature['name'] );
763
764 if ( self::STATE_ACTIVE === $new_state ) {
765 if ( empty( $feature['dependencies'] ) ) {
766 return;
767 }
768
769 // Validate if the current feature dependency is available.
770 foreach ( $feature['dependencies'] as $dependency ) {
771 $dependency_feature = $this->get_features( $dependency->get_name() );
772
773 if ( ! $dependency_feature ) {
774 $rollback( $feature_option_key, self::STATE_INACTIVE );
775
776 throw new Exceptions\Dependency_Exception(
777 sprintf(
778 'The feature `%s` has a dependency `%s` that is not available.',
779 esc_html( $feature['name'] ),
780 esc_html( $dependency->get_name() )
781 )
782 );
783 }
784
785 $dependency_state = $this->get_feature_actual_state( $dependency_feature );
786
787 // If dependency is not active.
788 if ( self::STATE_INACTIVE === $dependency_state ) {
789 $rollback( $feature_option_key, self::STATE_INACTIVE );
790
791 throw new Exceptions\Dependency_Exception(
792 sprintf(
793 'To turn on `%1$s`, Experiment: `%2$s` activity is required!',
794 esc_html( $feature['name'] ),
795 esc_html( $dependency_feature['name'] )
796 )
797 );
798 }
799 }
800 } elseif ( self::STATE_INACTIVE === $new_state ) {
801 // Make sure to deactivate a dependant experiment of the current feature when it's deactivated.
802 foreach ( $this->get_features() as $current_feature ) {
803 if ( empty( $current_feature['dependencies'] ) ) {
804 continue;
805 }
806
807 $current_feature_state = $this->get_feature_actual_state( $current_feature );
808
809 foreach ( $current_feature['dependencies'] as $dependency ) {
810 if ( self::STATE_ACTIVE === $current_feature_state && $feature['name'] === $dependency->get_name() ) {
811 update_option( $this->get_feature_option_key( $current_feature['name'] ), static::STATE_INACTIVE );
812 }
813 }
814 }
815 }
816 }
817
818 private function should_show_hidden() {
819 return defined( 'ELEMENTOR_SHOW_HIDDEN_EXPERIMENTS' ) && ELEMENTOR_SHOW_HIDDEN_EXPERIMENTS;
820 }
821
822 private function create_dependency_class( $dependency_name, $dependency_args ) {
823 if ( class_exists( $dependency_name ) ) {
824 return $dependency_name::instance();
825 }
826
827 if ( ! empty( $dependency_args ) ) {
828 return new Wrap_Core_Dependency( $dependency_args );
829 }
830
831 return new Non_Existing_Dependency( $dependency_name );
832 }
833
834 /**
835 * The experiments page is a WordPress options page, which means all the experiments are registered via WordPress' register_settings(),
836 * and their states are being sent in the POST request when saving.
837 * The options are being updated in a chronological order based on the POST data.
838 * This behavior interferes with the experiments dependency mechanism because the data that's being sent can be in any order,
839 * while the dependencies mechanism expects it to be in a specific order (dependencies should be activated before their dependents can).
840 * In order to solve this issue, we sort the experiments in the POST data based on their dependencies tree.
841 *
842 * @param array $allowed_options
843 */
844 private function sort_allowed_options_by_dependencies( $allowed_options ) {
845 if ( ! isset( $allowed_options['elementor'] ) ) {
846 return $allowed_options;
847 }
848
849 $sorted = Collection::make();
850 $visited = Collection::make();
851
852 $sort = function ( $item ) use ( &$sort, $sorted, $visited ) {
853 if ( $visited->contains( $item ) ) {
854 return;
855 }
856
857 $visited->push( $item );
858
859 $feature = $this->get_features( $item );
860
861 if ( ! $feature ) {
862 return;
863 }
864
865 foreach ( $feature['dependencies'] ?? [] as $dep ) {
866 $name = is_string( $dep ) ? $dep : $dep->get_name();
867
868 $sort( $name );
869 }
870
871 $sorted->push( $item );
872 };
873
874 foreach ( $allowed_options['elementor'] as $option ) {
875 $is_experiment_option = strpos( $option, static::OPTION_PREFIX ) === 0;
876
877 if ( ! $is_experiment_option ) {
878 continue;
879 }
880
881 $sort(
882 str_replace( static::OPTION_PREFIX, '', $option )
883 );
884 }
885
886 $allowed_options['elementor'] = Collection::make( $allowed_options['elementor'] )
887 ->filter( function ( $option ) {
888 return 0 !== strpos( $option, static::OPTION_PREFIX );
889 } )
890 ->merge(
891 $sorted->map( function ( $item ) {
892 return static::OPTION_PREFIX . $item;
893 } )
894 )
895 ->values();
896
897 return $allowed_options;
898 }
899
900 public function __construct() {
901 $this->init_states();
902
903 $this->init_release_statuses();
904
905 $this->init_features();
906
907 add_action( 'admin_init', function () {
908 System_Info::add_report(
909 'experiments', [
910 'file_name' => __DIR__ . '/experiments-reporter.php',
911 'class_name' => __NAMESPACE__ . '\Experiments_Reporter',
912 ]
913 );
914 }, 79 /* Before log */ );
915
916 if ( is_admin() ) {
917 $page_id = Settings::PAGE_ID;
918
919 add_action( "elementor/admin/after_create_settings/{$page_id}", function( Settings $settings ) {
920 $this->register_settings_fields( $settings );
921 }, 11 );
922
923 add_filter( 'allowed_options', function ( $allowed_options ) {
924 return $this->sort_allowed_options_by_dependencies( $allowed_options );
925 }, 11 );
926 }
927
928 // Register CLI commands.
929 if ( Utils::is_wp_cli() ) {
930 \WP_CLI::add_command( 'elementor experiments', WP_CLI::class );
931 }
932 }
933
934 /**
935 * @param array $experimental_data
936 * @return array
937 *
938 * @throws Dependency_Exception If the feature dependency is not initialized or depends on a hidden experiment.
939 */
940 private function initialize_feature_dependencies( array $experimental_data ): array {
941 foreach ( $experimental_data['dependencies'] as $key => $dependency ) {
942 $feature = $this->get_features( $dependency );
943
944 if ( ! isset( $feature ) ) {
945 // since we must validate the state of each dependency, we have to make sure that dependencies are initialized in the correct order, otherwise, error.
946 throw new Exceptions\Dependency_Exception(
947 sprintf(
948 'Feature %s cannot be initialized before dependency feature: %s.',
949 esc_html( $experimental_data['name'] ),
950 esc_html( $dependency )
951 )
952 );
953 }
954
955 if ( ! empty( $feature[ static::TYPE_HIDDEN ] ) ) {
956 throw new Exceptions\Dependency_Exception( 'Depending on a hidden experiment is not allowed.' );
957 }
958
959 $experimental_data['dependencies'][ $key ] = $this->create_dependency_class( $dependency, $feature );
960 $experimental_data = $this->set_feature_default_state_to_match_dependencies( $feature, $experimental_data );
961 }
962
963 return $experimental_data;
964 }
965
966 /**
967 * @param array $feature
968 * @param array $experimental_data
969 * @return array
970 *
971 * we must validate the state:
972 * * A user can set a dependant feature to inactive and in upgrade we don't change users settings.
973 * * A developer can set the default state to be invalid (e.g. dependant feature is inactive).
974 * if one of the dependencies is inactive, the main feature should be inactive as well.
975 */
976 private function set_feature_default_state_to_match_dependencies( array $feature, array $experimental_data ): array {
977 if ( self::STATE_INACTIVE !== $this->get_feature_actual_state( $feature ) ) {
978 return $experimental_data;
979 }
980
981 if ( self::STATE_ACTIVE === $experimental_data['state'] ) {
982 $experimental_data['state'] = self::STATE_INACTIVE;
983 } elseif ( self::STATE_DEFAULT === $experimental_data['state'] ) {
984 $experimental_data['default'] = self::STATE_INACTIVE;
985 }
986
987 return $experimental_data;
988 }
989
990 /**
991 * @param array $new_site
992 * @param array $experimental_data
993 * @return array
994 */
995 private function set_new_site_default_state( $new_site, array $experimental_data ): array {
996 if ( ! $this->install_compare( $new_site['minimum_installation_version'] ) ) {
997 return $experimental_data;
998 }
999
1000 if ( $new_site['always_active'] ) {
1001 $experimental_data['state'] = self::STATE_ACTIVE;
1002 $experimental_data['mutable'] = false;
1003 } elseif ( $new_site['default_active'] ) {
1004 $experimental_data['default'] = self::STATE_ACTIVE;
1005 } elseif ( $new_site['default_inactive'] ) {
1006 $experimental_data['default'] = self::STATE_INACTIVE;
1007 }
1008
1009 return $experimental_data;
1010 }
1011
1012 /**
1013 * @param array $options
1014 * @return array
1015 */
1016 private function set_feature_initial_options( array $options ): array {
1017 $default_experimental_data = [
1018 'tag' => '', // Deprecated, use 'tags' instead.
1019 'tags' => [],
1020 'description' => '',
1021 'release_status' => self::RELEASE_STATUS_ALPHA,
1022 'default' => self::STATE_INACTIVE,
1023 'mutable' => true,
1024 static::TYPE_HIDDEN => false,
1025 'new_site' => [
1026 'always_active' => false,
1027 'default_active' => false,
1028 'default_inactive' => false,
1029 'minimum_installation_version' => null,
1030 ],
1031 'on_state_change' => null,
1032 'generator_tag' => false,
1033 'deprecated' => false,
1034 ];
1035
1036 $allowed_options = [ 'name', 'title', 'tag', 'tags', 'description', 'release_status', 'default', 'mutable', static::TYPE_HIDDEN, 'new_site', 'on_state_change', 'dependencies', 'generator_tag', 'messages', 'deprecated' ];
1037 $experimental_data = $this->merge_properties( $default_experimental_data, $options, $allowed_options );
1038
1039 return $this->unify_feature_tags( $experimental_data );
1040 }
1041 }
1042