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

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