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

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