PluginProbe
Elementor Website Builder – more than just a page builder / 3.27.3
Elementor Website Builder – more than just a page builder v3.27.3
4.3.0-beta2 4.3.0-beta1 4.2.4 4.2.3 4.2.2 4.2.1 4.2.0 4.1.5 4.2.0-beta2 4.2.0-dev2 4.2.0-beta1 4.1.4 4.1.3 4.1.2 4.1.1 4.1.0 4.1.0-beta3 4.1.0-dev3 4.0.9 4.1.0-beta2 4.1.0-dev2 4.0.8 4.1.0-beta1 4.1.0-dev1 4.0.7 All 451 releases
elementor / core / experiments / manager.php

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

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