PluginProbe
Elementor Website Builder – more than just a page builder / 4.0.0-beta1
Elementor Website Builder – more than just a page builder v4.0.0-beta1
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 4.0.0-beta1, at core/experiments/manager.php

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