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

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