*/ public function available_types() { $types = CustomerQuery::types(); if ( ! Orders::hpos() ) { $types = array_diff_key( $types, array_flip( CustomerQuery::order_types() ) ); } $tags = Plugin::instance()->registry()->get( 'customer-tags' ); if ( ! $tags || ! $tags->is_enabled() || ! Groups::all() ) { unset( $types['tag'] ); } /** * Filter the customer search types (key => label). Build a new type's SQL with * `apwc_customer_search_where`. * * @since 1.0.0 * @param array $types Types. */ return (array) apply_filters( 'apwc_customer_search_types', $types ); } /** * {@inheritDoc} Types this store can't use right now (no HPOS, Customer tags off or without * tag groups) aren't in the form, so saving keeps their previous state instead of switching * them off. * * @since 1.0.0 * @param array $field Field schema. * @param mixed $value Submitted value. * @return mixed */ protected function sanitize_field( array $field, $value ) { $clean = parent::sanitize_field( $field, $value ); if ( 'types' !== $field['id'] ) { return $clean; } /** This filter is documented in available_types(). */ $all = array_keys( (array) apply_filters( 'apwc_customer_search_types', CustomerQuery::types() ) ); $hidden = array_diff( $all, array_keys( $this->available_types() ) ); $before = array_map( 'strval', (array) $this->setting( 'types' ) ); return array_values( array_unique( array_merge( array_map( 'strval', (array) $clean ), array_intersect( $before, $hidden ) ) ) ); } /** * {@inheritDoc} * * @return array> */ public function fields() { $types = $this->available_types(); $note = array(); if ( ! Orders::hpos() ) { $note[] = __( 'Order, product and spend searches need High-performance order storage.', 'admin-powerups-for-woocommerce' ); } if ( ! isset( $types['tag'] ) ) { $note[] = __( 'Customer tag search appears when Customer tags is on and has a tag group.', 'admin-powerups-for-woocommerce' ); } return array( array( 'id' => 'types', 'type' => 'checkboxes', 'label' => __( 'Search by', 'admin-powerups-for-woocommerce' ), 'description' => implode( ' ', array_merge( array( __( 'Shown in a dropdown next to Search Users. “Default” is WordPress’s own user search, unchanged.', 'admin-powerups-for-woocommerce' ) ), $note ) ), 'default' => array_keys( CustomerQuery::types() ), 'options' => $types, 'slow' => array_values( array_intersect( CustomerQuery::slow_types(), array_keys( $types ) ) ), 'warning' => __( 'Types marked Slower look inside customer details or add up orders, which can’t use database indexes. On stores with many customers or orders a search can take a few seconds. Order number, SKU, product, tag and order date stay fast.', 'admin-powerups-for-woocommerce' ), ), ); } /** * {@inheritDoc} */ public function boot() { add_action( 'manage_users_extra_tablenav', array( $this, 'render_select' ) ); add_filter( 'users_list_table_query_args', array( $this, 'list_query_args' ) ); add_action( 'pre_user_query', array( $this, 'filter_users' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) ); } /** * Types turned on in settings that the current user may use, in display order. * * @since 1.0.0 * @return array */ public function enabled_types() { $on = array_map( 'strval', (array) $this->setting( 'types' ) ); return array_filter( $this->available_types(), fn( $key ) => in_array( (string) $key, $on, true ) && $this->can_use_type( (string) $key ), ARRAY_FILTER_USE_KEY ); } /** * Whether the current user may search by a type. Anyone who can open the Users list * (list_users) may be looking, and every type reveals customer data WordPress's own search * doesn't: customer details need manage_woocommerce, order types (order number, SKU, * product, order count, spend, order date) manage_woocommerce or edit_shop_orders. Types added * with `apwc_customer_search_types` default to manage_woocommerce. * * @since 1.0.0 * @param string $type Type key. * @return bool */ public function can_use_type( $type ) { $caps = in_array( (string) $type, CustomerQuery::order_types(), true ) ? array( 'manage_woocommerce', 'edit_shop_orders' ) : array( 'manage_woocommerce' ); /** * Filter the capabilities that let a user search the Users list by a type. Any one of * them is enough; an empty list allows everyone who can list users. * * @since 1.0.0 * @param string[] $caps Capabilities (any of). * @param string $type Type key. */ $caps = (array) apply_filters( 'apwc_customer_search_type_capabilities', $caps, (string) $type ); if ( ! $caps ) { return true; } foreach ( $caps as $cap ) { if ( is_string( $cap ) && '' !== $cap && current_user_can( $cap ) ) { return true; } } return false; } /** * Type chosen for this request, or ''. * * @since 1.0.0 * @return string */ private function current_type() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only list search. $type = isset( $_GET[ self::ARG ] ) ? sanitize_key( wp_unslash( $_GET[ self::ARG ] ) ) : ''; return isset( $this->enabled_types()[ $type ] ) ? $type : ''; } /** * Script that places the dropdown beside the search box. * * @since 1.0.0 * @param string $hook Admin page. * @return void */ public function enqueue( $hook ) { if ( 'users.php' === $hook && $this->enabled_types() ) { wp_enqueue_script( 'apwc-customer-search', Assets::url( 'assets/js/customer-search.js' ), array(), Assets::ver( 'assets/js/customer-search.js' ), true ); wp_enqueue_style( 'apwc-modules', Assets::url( 'assets/css/modules.css' ), array(), Assets::ver( 'assets/css/modules.css' ) ); } } /** * "Search by" dropdown. Printed in the list form (so it's submitted with the search even * without scripts); customer-search.js moves it next to the search box. * * @since 1.0.0 * @param string $which top|bottom. * @return void */ public function render_select( $which ) { $types = $this->enabled_types(); if ( 'top' !== $which || ! $types ) { return; } $current = $this->current_type(); $hints = CustomerQuery::hints(); ?> $args WP_User_Query args. * @return array */ public function list_query_args( $args ) { $type = $this->current_type(); // Not sanitize_text_field(): it turns "<500" into "<500". The term only reaches SQL through // prepare()/esc_like(), so dropping control characters is enough. // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- read-only list search, cleaned below. $term = isset( $_REQUEST['s'] ) && is_string( $_REQUEST['s'] ) ? self::clean_term( wp_unslash( $_REQUEST['s'] ) ) : ''; if ( '' === $type || '' === $term ) { return $args; } unset( $args['search'], $args['search_columns'] ); $args['apwc_search'] = array( $type, $term ); return $args; } /** * Search term as typed: trimmed, without control characters or invalid UTF-8, and with * comparison signs ("<", "<=") kept. * * @since 1.0.0 * @param string $term Raw (unslashed) term. * @return string */ public static function clean_term( $term ) { $term = wp_check_invalid_utf8( (string) $term ); return trim( (string) preg_replace( '/[\x00-\x1F\x7F]+/', ' ', $term ) ); } /** * Add the search condition. * * @since 1.0.0 * @param WP_User_Query $query Query. * @return void */ public function filter_users( $query ) { $search = $query->get( 'apwc_search' ); if ( ! is_array( $search ) || 2 !== count( $search ) ) { return; } if ( ! $this->can_use_type( (string) $search[0] ) ) { // Set by someone else's code: still never run a type this user may not use. $query->query_where .= ' AND 1=0'; return; } $query->query_where .= ' AND ' . ( new CustomerQuery() )->where( (string) $search[0], (string) $search[1] ); } }