PluginProbe
Elementor Website Builder – more than just a page builder / 3.25.3
Elementor Website Builder – more than just a page builder v3.25.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.25.3, at core/experiments/manager.php

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