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

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