PluginProbe
Elementor Website Builder – more than just a page builder / 3.32.0-dev2
Elementor Website Builder – more than just a page builder v3.32.0-dev2
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.32.0-dev2, at core/experiments/manager.php

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