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

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