/**
* Get Feature Settings Label HTML
*
* @since 3.1.0
* @access private
*
* @param string $feature_name
*
* @return int
*/
private function get_saved_feature_state( $feature_name ) {
return get_option( $this->get_feature_option_key( $feature_name ) );
}
/**
* Get Feature Actual State
*
* @since 3.1.0
* @access private
*
* @param array $feature
*
* @return string
*/
private function get_feature_actual_state( array $feature ) {
if ( ! empty( $feature['state'] ) && self::STATE_DEFAULT !== $feature['state'] ) {
return $feature['state'];
}
return $feature['default'];
}
/**
* On Feature State Change
*
* @since 3.1.0
* @access private
*
* @param array $old_feature_data
* @param string $new_state
* @param string $old_state
*/
private function on_feature_state_change( array $old_feature_data, $new_state, $old_state ) {
$new_feature_data = $this->get_features( $old_feature_data['name'] );
$this->validate_dependency( $new_feature_data, $new_state );
$this->features[ $old_feature_data['name'] ]['state'] = $new_state;
if ( $old_state === $new_state ) {
return;
}
Plugin::$instance->files_manager->clear_cache();
if ( $new_feature_data['on_state_change'] ) {
$new_feature_data['on_state_change']( $old_state, $new_state );
}
do_action( 'elementor/experiments/feature-state-change/' . $old_feature_data['name'], $old_state, $new_state );
}
/**
* @throws Exceptions\Dependency_Exception If the feature dependency is not available or not active.
*/
private function validate_dependency( array $feature, $new_state ) {
$rollback = function ( $feature_option_key, $state ) {
remove_all_actions( 'add_option_' . $feature_option_key );
remove_all_actions( 'update_option_' . $feature_option_key );
update_option( $feature_option_key, $state );
};
if ( self::STATE_DEFAULT === $new_state ) {
$new_state = $this->get_feature_actual_state( $feature );
}
$feature_option_key = $this->get_feature_option_key( $feature['name'] );
if ( self::STATE_ACTIVE === $new_state ) {
if ( empty( $feature['dependencies'] ) ) {
return;
}
// Validate if the current feature dependency is available.
foreach ( $feature['dependencies'] as $dependency ) {
$dependency_feature = $this->get_features( $dependency->get_name() );
if ( ! $dependency_feature ) {
$rollback( $feature_option_key, self::STATE_INACTIVE );
throw new Exceptions\Dependency_Exception(
sprintf(
'The feature `%s` has a dependency `%s` that is not available.',
esc_html( $feature['name'] ),
esc_html( $dependency->get_name() )
)
);
}
$dependency_state = $this->get_feature_actual_state( $dependency_feature );
// If dependency is not active.
if ( self::STATE_INACTIVE === $dependency_state ) {
$rollback( $feature_option_key, self::STATE_INACTIVE );
throw new Exceptions\Dependency_Exception(
sprintf(
'To turn on `%1$s`, Experiment: `%2$s` activity is required!',
esc_html( $feature['name'] ),
esc_html( $dependency_feature['name'] )
)
);
}
}
} elseif ( self::STATE_INACTIVE === $new_state ) {
// Make sure to deactivate a dependant experiment of the current feature when it's deactivated.
foreach ( $this->get_features() as $current_feature ) {
if ( empty( $current_feature['dependencies'] ) ) {
continue;
}
$current_feature_state = $this->get_feature_actual_state( $current_feature );
foreach ( $current_feature['dependencies'] as $dependency ) {
if ( self::STATE_ACTIVE === $current_feature_state && $feature['name'] === $dependency->get_name() ) {
update_option( $this->get_feature_option_key( $current_feature['name'] ), static::STATE_INACTIVE );
}
}
}
}
}
private function should_show_hidden() {
return defined( 'ELEMENTOR_SHOW_HIDDEN_EXPERIMENTS' ) && ELEMENTOR_SHOW_HIDDEN_EXPERIMENTS;
}
private function create_dependency_class( $dependency_name, $dependency_args ) {
if ( class_exists( $dependency_name ) ) {
return $dependency_name::instance();
}
if ( ! empty( $dependency_args ) ) {
return new Wrap_Core_Dependency( $dependency_args );
}
return new Non_Existing_Dependency( $dependency_name );
}
/**
* The experiments page is a WordPress options page, which means all the experiments are registered via WordPress' register_settings(),
* and their states are being sent in the POST request when saving.
* The options are being updated in a chronological order based on the POST data.
* This behavior interferes with the experiments dependency mechanism because the data that's being sent can be in any order,
* while the dependencies mechanism expects it to be in a specific order (dependencies should be activated before their dependents can).
* In order to solve this issue, we sort the experiments in the POST data based on their dependencies tree.
*
* @param array $allowed_options
*/
private function sort_allowed_options_by_dependencies( $allowed_options ) {
if ( ! isset( $allowed_options['elementor'] ) ) {
return $allowed_options;
}
$sorted = Collection::make();
$visited = Collection::make();
$sort = function ( $item ) use ( &$sort, $sorted, $visited ) {
if ( $visited->contains( $item ) ) {
return;
}
$visited->push( $item );
$feature = $this->get_features( $item );
if ( ! $feature ) {
return;
}
foreach ( $feature['dependencies'] ?? [] as $dep ) {
$name = is_string( $dep ) ? $dep : $dep->get_name();
$sort( $name );
}
$sorted->push( $item );
};
foreach ( $allowed_options['elementor'] as $option ) {
$is_experiment_option = strpos( $option, static::OPTION_PREFIX ) === 0;
if ( ! $is_experiment_option ) {
continue;
}
$sort(
str_replace( static::OPTION_PREFIX, '', $option )
);
}
$allowed_options['elementor'] = Collection::make( $allowed_options['elementor'] )
->filter( function ( $option ) {
return 0 !== strpos( $option, static::OPTION_PREFIX );
} )
->merge(
$sorted->map( function ( $item ) {
return static::OPTION_PREFIX . $item;
} )
)
->values();
return $allowed_options;
}
public function __construct() {
$this->init_states();
$this->init_release_statuses();
$this->init_features();
add_action( 'admin_init', function () {
System_Info::add_report(
'experiments', [
'file_name' => __DIR__ . '/experiments-reporter.php',
'class_name' => __NAMESPACE__ . '\Experiments_Reporter',
]
);
}, 79 /* Before log */ );
if ( is_admin() ) {
$page_id = Settings::PAGE_ID;
add_action( "elementor/admin/after_create_settings/{$page_id}", function( Settings $settings ) {
$this->register_settings_fields( $settings );
}, 11 );
add_filter( 'allowed_options', function ( $allowed_options ) {
return $this->sort_allowed_options_by_dependencies( $allowed_options );
}, 11 );
}
// Register CLI commands.
if ( Utils::is_wp_cli() ) {
\WP_CLI::add_command( 'elementor experiments', WP_CLI::class );
}
}
/**
* @param array $experimental_data
* @return array
*
* @throws Exceptions\Dependency_Exception If the feature dependency is not initialized or depends on a hidden experiment.
*/
private function initialize_feature_dependencies( array $experimental_data ): array {
foreach ( $experimental_data['dependencies'] as $key => $dependency ) {
$feature = $this->get_features( $dependency );
if ( ! isset( $feature ) ) {
// since we must validate the state of each dependency, we have to make sure that dependencies are initialized in the correct order, otherwise, error.
throw new Exceptions\Dependency_Exception(
sprintf(
'Feature %s cannot be initialized before dependency feature: %s.',
esc_html( $experimental_data['name'] ),
esc_html( $dependency )
)
);
}
if ( ! empty( $feature[ static::TYPE_HIDDEN ] ) ) {
throw new Exceptions\Dependency_Exception( 'Depending on a hidden experiment is not allowed.' );
}
$experimental_data['dependencies'][ $key ] = $this->create_dependency_class( $dependency, $feature );
$experimental_data = $this->set_feature_default_state_to_match_dependencies( $feature, $experimental_data );
}
return $experimental_data;
}
/**
* @param array $feature
* @param array $experimental_data
* @return array
*
* we must validate the state:
* * A user can set a dependant feature to inactive and in upgrade we don't change users settings.
* * A developer can set the default state to be invalid (e.g. dependant feature is inactive).
* if one of the dependencies is inactive, the main feature should be inactive as well.
*/
private function set_feature_default_state_to_match_dependencies( array $feature, array $experimental_data ): array {
if ( self::STATE_INACTIVE !== $this->get_feature_actual_state( $feature ) ) {
return $experimental_data;
}
if ( self::STATE_ACTIVE === $experimental_data['state'] ) {
$experimental_data['state'] = self::STATE_INACTIVE;
} elseif ( self::STATE_DEFAULT === $experimental_data['state'] ) {
$experimental_data['default'] = self::STATE_INACTIVE;
}
return $experimental_data;
}
/**
* @param array $new_site
* @param array $experimental_data
* @return array
*/
private function set_new_site_default_state( $new_site, array $experimental_data ): array {
if ( ! $this->install_compare( $new_site['minimum_installation_version'] ) ) {
return $experimental_data;
}
if ( $new_site['always_active'] ) {
$experimental_data['state'] = self::STATE_ACTIVE;
$experimental_data['mutable'] = false;
} elseif ( $new_site['default_active'] ) {
$experimental_data['default'] = self::STATE_ACTIVE;
} elseif ( $new_site['default_inactive'] ) {
$experimental_data['default'] = self::STATE_INACTIVE;
}
return $experimental_data;
}
/**
* @param array $options
* @return array
*/
private function set_feature_initial_options( array $options ): array {
$default_experimental_data = [
'tag' => '', // Deprecated, use 'tags' instead.
'tags' => [],
'description' => '',
'release_status' => self::RELEASE_STATUS_ALPHA,
'default' => self::STATE_INACTIVE,
'mutable' => true,
static::TYPE_HIDDEN => false,
'new_site' => [
'always_active' => false,
'default_active' => false,
'default_inactive' => false,
'minimum_installation_version' => null,
],
'on_state_change' => null,
'generator_tag' => false,
'deprecated' => false,
];
$allowed_options = [ 'name', 'title', 'tag', 'tags', 'description', 'release_status', 'default', 'mutable', static::TYPE_HIDDEN, 'new_site', 'on_state_change', 'dependencies', 'generator_tag', 'messages', 'deprecated' ];
$experimental_data = $this->merge_properties( $default_experimental_data, $options, $allowed_options );
return $this->unify_feature_tags( $experimental_data );
}
}