PluginProbe
Elementor Website Builder – more than just a page builder / 4.3.4
Elementor Website Builder – more than just a page builder v4.3.4
4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.3.0-beta3 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 All 457 releases
elementor / core / experiments / manager.php

manager.php in Elementor Website Builder – more than just a page builder 4.3.4, at core/experiments/manager.php

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