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

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