View file File name : module.php Content :<?php namespace ElementorPro\Modules\LoopFilter; use Elementor\Core\Experiments\Manager; use ElementorPro\Base\Module_Base; use ElementorPro\Core\Utils; use ElementorPro\Modules\LoopFilter\Query\Taxonomy_Query_Builder; use ElementorPro\Modules\LoopFilter\Query\Data\Query_Constants; use ElementorPro\Modules\LoopFilter\Data\Controller; use ElementorPro\Plugin; use ElementorPro\Modules\LoopFilter\Traits\Hierarchical_Taxonomy_Trait; if ( ! defined( 'ABSPATH' ) ) { exit; // Exit if accessed directly } class Module extends Module_Base { use Hierarchical_Taxonomy_Trait; const EXPERIMENT_NAME = 'taxonomy-filter'; private $operator; private $taxonomy; private $query; /** * @var array Array of widgets containing each widget's filters which are tied to the current session. */ private $filters = []; protected function get_widgets() { return [ 'Taxonomy_Filter' ]; } public function get_name() { return 'loop-filter'; } public static function is_active() { return Plugin::elementor()->experiments->is_feature_active( static::EXPERIMENT_NAME ); } /** * Add to the experiments * * @return array */ public static function get_experimental_data() { $experiment_data = [ 'name' => static::EXPERIMENT_NAME, 'title' => esc_html__( 'Taxonomy Filter', 'elementor-pro' ), 'description' => sprintf( esc_html__( 'Taxonomy Filter is a powerful tool that enables users to easily filter and sort their posts and product categories. %1$sLearn More%2$s', 'elementor-pro' ), '<a href="https://go.elementor.com/wp-dash-taxonomy-filter/" target="_blank">', '</a>' ), 'release_status' => Manager::RELEASE_STATUS_BETA, 'default' => Manager::STATE_ACTIVE, ]; return $experiment_data; } public function get_post_type_taxonomies( $data ) { if ( ! current_user_can( 'edit_posts' ) || empty( $data['post_type'] ) ) { return []; } $post_type_taxonomies = get_object_taxonomies( $data['post_type'], 'objects' ); $control_options = []; foreach ( $post_type_taxonomies as $taxonomy ) { $control_options[ $taxonomy->name ] = $taxonomy->label; } return $control_options; } public function register_widget_filter( $widget_id, $filter_data ) { if ( empty( $this->filters[ $widget_id ] ) ) { $this->filters[ $widget_id ] = $filter_data; return; } foreach ( $filter_data as $filter_type => $filter ) { if ( empty( $this->filters[ $widget_id ][ $filter_type ] ) ) { $this->filters[ $widget_id ][ $filter_type ] = $filter; continue; } $this->filters[ $widget_id ][ $filter_type ] = array_merge( $this->filters[ $widget_id ][ $filter_type ], $filter ); } } public function filter_loop_query( $query_args, $widget ) { $widget_id = $widget->get_id(); if ( empty( $this->filters[ $widget_id ] ) ) { return $query_args; } /** @var array $filter_types An array containing all of a widget's different filters - e.g. taxonomy, price, rating... */ $filter_types = $this->filters[ $widget_id ]; // TODO: This part is non-generic and should be refactored to allow for multiple types of filters. $query_args['tax_query']['relation'] = $this->query['AND']['relation']; foreach ( $filter_types as $filters ) { // The $filters array contains all filters of a specific type. For example, for the taxonomy filter type, // it would contain all taxonomies to be filtered - e.g. 'category', 'tag', 'product-cat', etc. $tax_query = []; foreach ( $filters as $filter_taxonomy => $filter ) { if ( 'logicalJoin' === $filter_taxonomy ) { continue; } if ( $this->is_filter_empty( $filter ) ) { continue; } // Sanitize request data. $taxonomy = sanitize_key( $filter_taxonomy ); ( new Taxonomy_Query_Builder() )->get_merged_queries( $tax_query, $taxonomy, $filter ); } } $query_args['tax_query'] = array_merge( $query_args['tax_query'], $tax_query ?? [] ); return $query_args; } /** * @description Check if the filter is empty. * Taxonomy Filter URL parameter is empty but not removed i.e. `&e-filter-389c132-product_cat=`. * This edge case happens if a user clears terms and not the Taxonomy filter parameter * @param $filter * @return bool */ public function is_filter_empty( $filter ) { if ( '' === $filter['terms'][0] ) { return true; } return false; } public function add_localize_data( $config ) { $current_query_vars = $this->get_current_query_vars(); $config['loopFilter'] = [ 'mainQueryPostType' => $current_query_vars['post_type'] ?? 'post', 'nonce' => wp_create_nonce( 'wp_rest' ), ]; return $config; } private function get_current_query_vars() { $current_query_vars = $GLOBALS['wp_query']->query_vars; /** * Current query variables. * * Filters the query variables for the current query. This hook allows * developers to alter those variables. * * @param array $current_query_vars Current query variables. */ return apply_filters( 'elementor/query/get_query_args/current_query', $current_query_vars ); } private function parse_query_string( $param_key ) { // Check if the query param is a filter. match a regex for `e-filter-14f9e1d-post_tag` where `14f9e1d` is the widget ID and must be 7 characters long and have only letters and numbers, then followed by a string that can only have letters, numbers, dashes and underscores. if ( ! preg_match( '/^e-filter-[a-z0-9]{7}-[a-z0-9_\-]+$/', $param_key ) ) { return []; } // Remove the 'filter_' prefix from the query param $filter = str_replace( 'e-filter-', '', $param_key ); // Split the filter into an array of widget ID and filter type $filter = explode( '-', $filter ); if ( count( $filter ) !== 2 ) { return []; } // Get the widget ID $widget_id = $filter[0]; // Get the taxonomy $taxonomy = $filter[1]; return [ 'taxonomy' => $taxonomy, 'widget_id' => $widget_id, ]; } private function maybe_populate_filters_from_query_string() { if ( ! isset( $_SERVER['QUERY_STRING'] ) ) { return; } $query_params = []; wp_parse_str( htmlspecialchars_decode( Utils::_unstable_get_super_global_value( $_SERVER, 'QUERY_STRING' ) ), $query_params ); foreach ( $query_params as $param_key => $param_value ) { // TODO: This part is not generic - it only supports taxonomy filters. It should be refactored to allow for multiple types of filters. $parsed_query_string = $this->parse_query_string( $param_key ); if ( empty( $parsed_query_string ) || empty( $parsed_query_string['taxonomy'] ) || empty( $parsed_query_string['widget_id'] ) ) { continue; } $terms = $this->get_terms_array_from_params( $param_value ); $logical_join = $this->get_logical_join_from_params( $param_value ); if ( empty( $terms ) ) { continue; } $filter_data = [ 'taxonomy' => [ $parsed_query_string['taxonomy'] => [ 'terms' => $terms, 'logicalJoin' => $logical_join, ], ], ]; $this->register_widget_filter( $parsed_query_string['widget_id'], $filter_data ); } } private function get_seperator_from_params( $param_value ) { $separator = $this->query['AND']['separator']['from-browser']; // The web browser automatically replaces the plus sign with a space character before sending the data to the server. if ( strstr( $param_value, $this->query['OR']['separator']['from-browser'] ) ) { $separator = $this->query['OR']['separator']['from-browser']; $this->operator = $this->query['OR']['operator']; } return $separator; } private function get_terms_array_from_params( $param_value ) { $separator = $this->get_seperator_from_params( $param_value ); return explode( $separator, $param_value ); } private function get_logical_join_from_params( $param_value ) { $separator = $this->get_seperator_from_params( $param_value ); foreach ( $this->query as $index => $data ) { if ( $data['separator']['decoded'] === $separator ) { return $index; // Return the index when the decoded separator is found } } return 'AND'; // Default logical join } /** * @return array */ public function get_query_string_filters() { return $this->filters; } public function remove_rest_route_parameter( $link ) { return remove_query_arg( 'rest_route', $link ); } /** * @return boolean */ public function is_term_not_selected_for_inclusion( $loop_widget_settings, $term, $skin ) { return ! empty( $loop_widget_settings[ $skin . '_query_include' ] ) && in_array( 'terms', $loop_widget_settings[ $skin . '_query_include' ] ) && isset( $loop_widget_settings[ $skin . '_query_include_term_ids' ] ) && ! in_array( $term->term_id, $loop_widget_settings[ $skin . '_query_include_term_ids' ] ); } /** * @return boolean */ public function is_term_selected_for_exclusion( $loop_widget_settings, $term, $skin ) { return ! empty( $loop_widget_settings[ $skin . '_query_exclude' ] ) && in_array( 'terms', $loop_widget_settings[ $skin . '_query_exclude' ] ) && isset( $loop_widget_settings[ $skin . '_query_exclude_term_ids' ] ) && in_array( $term->term_id, $loop_widget_settings[ $skin . '_query_exclude_term_ids' ] ); } /** * @return boolean */ public function should_exclude_term_by_manual_selection( $loop_widget_settings, $term, $user_selected_taxonomy, $skin ) { if ( ! $this->loop_widget_has_manual_selection( $loop_widget_settings, $skin ) ) { return false; } $terms_to_exclude_by_manual_selection = $this->get_terms_to_exclude_by_manual_selection( $loop_widget_settings[ $skin . '_query_exclude_ids' ] ?? [], $user_selected_taxonomy ); if ( in_array( $term->term_id, $terms_to_exclude_by_manual_selection ) ) { return true; } return false; } /** * @return boolean */ private function loop_widget_has_manual_selection( $loop_widget_settings, $skin ) { return ! empty( $loop_widget_settings[ $skin . '_query_exclude' ] ) && in_array( 'manual_selection', $loop_widget_settings[ $skin . '_query_exclude' ] ) && ! empty( $loop_widget_settings[ $skin . '_query_exclude_ids' ] ); } /** * @return array */ private function get_terms_to_exclude_by_manual_selection( $selected_posts, $user_selected_taxonomy ) { $terms_to_exclude = []; $term_exclude_counts = []; $term_actual_counts = []; foreach ( $selected_posts as $post_id ) { $this->calculate_post_terms_counts( $post_id, $user_selected_taxonomy, $term_exclude_counts, $term_actual_counts ); } foreach ( $term_exclude_counts as $term_id => $selected_count ) { $this->maybe_add_term_to_exclusion( $term_id, $selected_count, $term_actual_counts, $terms_to_exclude ); } return $terms_to_exclude; } /** * @return void */ private function calculate_post_terms_counts( $post_id, $user_selected_taxonomy, &$term_exclude_counts, &$term_actual_counts ) { $post_terms = wp_get_post_terms( $post_id, $user_selected_taxonomy ); foreach ( $post_terms as $term ) { $this->calculate_term_counts( $term, $term_exclude_counts, $term_actual_counts ); } } /** * @return void */ private function calculate_term_counts( $term, &$term_exclude_counts, &$term_actual_counts ) { if ( empty( $term_exclude_counts[ $term->term_id ] ) ) { $term_exclude_counts[ $term->term_id ] = 0; } $term_exclude_counts[ $term->term_id ] = (int) $term_exclude_counts[ $term->term_id ] + 1; $term_actual_counts[ $term->term_id ] = (int) $term->count; } /** * @return void */ private function maybe_add_term_to_exclusion( $term_id, $selected_count, $term_actual_counts, &$terms_to_exclude ) { $user_selected_all_the_posts_for_this_term = $selected_count >= $term_actual_counts[ $term_id ]; if ( $user_selected_all_the_posts_for_this_term ) { $terms_to_exclude[] = $term_id; } } public function __construct() { parent::__construct(); $this->query = Query_Constants::DATA; if ( ! empty( $_SERVER['QUERY_STRING'] ) ) { $this->maybe_populate_filters_from_query_string(); } // Register the controller. new Controller(); add_filter( 'elementor/query/query_args', [ $this, 'filter_loop_query' ], 10, 2 ); add_filter( 'elementor_pro/editor/localize_settings', [ $this, 'add_localize_data' ] ); add_filter( 'paginate_links', [ $this, 'remove_rest_route_parameter' ] ); } }