From 9988ef9d0efafc69673087ced3a91d001954647c Mon Sep 17 00:00:00 2001 From: David Parker Date: Tue, 14 Jul 2026 15:23:53 -0400 Subject: [PATCH] Rename Members List to Memberships and add Orders-style filter panel - Rename user-facing "Members"/"Members List" labels to "Memberships" on the list page, admin menu, admin bar, and header nav. Slugs, capabilities, and hook names are unchanged. Person-oriented actions (Add New Member, Search Members, Edit Member) keep "member" language. - Replace the overloaded level dropdown with a filter sidebar panel matching the Orders and Subscriptions pages: Level, Status (Active, Cancelled, Expired, All Ended), and an Ended Memberships selector controlling whether rows are hidden when the same level or any level is active again. - Level and status filters can now be combined, and ended views always hide rows where the user has re-activated the same level. - Add pmpro_sanitize_memberships_list_filters(), pmpro_memberships_list_filter_sql(), and pmpro_get_ended_membership_statuses() (filterable) so the list table and the members CSV export share one set of filter semantics. - Legacy l=cancelled|expired|oldmembers links are mapped to the new filters with their original users-with-no-active-membership behavior preserved. - Fix the End Date column rendering blank when filtering ended memberships with the new status parameter. Co-Authored-By: Claude Fable 5 --- adminpages/admin_header.php | 2 +- adminpages/memberslist.php | 15 +- classes/class-pmpro-exports.php | 29 +--- classes/class-pmpro-members-list-table.php | 190 +++++++++++++-------- includes/adminpages.php | 8 +- includes/functions.php | 143 ++++++++++++++++ 6 files changed, 284 insertions(+), 103 deletions(-) diff --git a/adminpages/admin_header.php b/adminpages/admin_header.php index bb34e8b8c1..bceffabac6 100644 --- a/adminpages/admin_header.php +++ b/adminpages/admin_header.php @@ -233,7 +233,7 @@ -
  • class="current"">
  • +
  • class="current"">
  • diff --git a/adminpages/memberslist.php b/adminpages/memberslist.php index 6c4ed2b3d4..199aa3d609 100644 --- a/adminpages/memberslist.php +++ b/adminpages/memberslist.php @@ -9,19 +9,16 @@ // Build CSV export link. // We now use the REST API for exports. Gather current filters to pass along when starting an export. -$members_export_filters = array(); +$members_export_filters = pmpro_sanitize_memberships_list_filters( $_REQUEST ); if ( isset( $_REQUEST['s'] ) ) { $members_export_filters['s'] = esc_attr( trim( sanitize_text_field( $_REQUEST['s'] ) ) ); } -if ( isset( $_REQUEST['l'] ) ) { - $members_export_filters['l'] = trim( sanitize_text_field( $_REQUEST['l'] ) ); -} // Render the List Table. ?>
    -

    +

    @@ -39,12 +36,16 @@ class="page-title-action pmpro-has-icon pmpro-has-icon-download pmpro-export-but - + search_box( __( 'Search Members', 'paid-memberships-pro' ), 'paid-memberships-pro' ); - $user_list_table->display(); ?> +
    +
    + display(); ?> +
    +
    'active' "; - $filter .= " AND NOT EXISTS ( SELECT 1 FROM {$wpdb->pmpro_memberships_users} mu2 WHERE mu2.user_id = u.ID AND mu2.status = 'active' ) "; - } - if ( 'expired' === $l || 'cancelled' === $l ) { - $statuses = ( 'expired' === $l ) ? array( 'expired' ) : array( 'cancelled', 'admin_cancelled' ); - $filter = " AND mu.status IN ('" . implode( "','", array_map( 'esc_sql', $statuses ) ) . "') "; - $filter .= " AND NOT EXISTS ( SELECT 1 FROM {$wpdb->pmpro_memberships_users} mu2 WHERE mu2.user_id = u.ID AND mu2.status = 'active' ) "; - } - if ( empty( $filter ) && is_numeric( $l ) ) { - $filter = " AND mu.status = 'active' AND mu.membership_id = " . (int) $l . ' '; - } - if ( empty( $filter ) ) { - $filter = " AND mu.status = 'active' "; - } - return $filter; + // Filters passed to older export URLs may not have been normalized yet. + $filters = pmpro_sanitize_memberships_list_filters( $filters ); + return pmpro_memberships_list_filter_sql( $filters ); } /** diff --git a/classes/class-pmpro-members-list-table.php b/classes/class-pmpro-members-list-table.php index fa752d8f17..1b5eb93cc0 100644 --- a/classes/class-pmpro-members-list-table.php +++ b/classes/class-pmpro-members-list-table.php @@ -28,9 +28,9 @@ public function __construct() { parent::__construct( array( - 'plural' => 'members', + 'plural' => 'memberships', // Plural value used for labels and the objects being listed. - 'singular' => 'member', + 'singular' => 'membership', // Singular label for an object being listed, e.g. 'post'. 'ajax' => false, // If true, the parent class will call the _js_vars() method in the footer @@ -49,7 +49,7 @@ public static function hook_screen_options() { 'per_page', array( 'default' => 20, - 'label' => __( 'Members per page', 'paid-memberships-pro' ), + 'label' => __( 'Memberships per page', 'paid-memberships-pro' ), 'option' => 'pmpro_members_per_page', ) ); @@ -144,17 +144,13 @@ public function get_columns() { 'enddate' => __( 'End Date', 'paid-memberships-pro' ), ); - if ( isset( $_REQUEST['l'] ) ) { - $l = sanitize_text_field( $_REQUEST['l'] ); - } else { - $l = false; - } + $filters = pmpro_sanitize_memberships_list_filters( $_REQUEST ); - if ( 'oldmembers' === $l ) { + if ( 'ended' === $filters['status'] ) { $columns['enddate'] = __( 'Ended', 'paid-memberships-pro' ); - } elseif ( 'expired' === $l ) { + } elseif ( 'expired' === $filters['status'] ) { $columns['enddate'] = __( 'Expired', 'paid-memberships-pro' ); - } elseif ( 'cancelled' === $l ) { + } elseif ( 'cancelled' === $filters['status'] ) { $columns['enddate'] = __( 'Cancelled', 'paid-memberships-pro' ); } @@ -279,29 +275,25 @@ protected function get_sortable_columns() { * @return void */ public function no_items() { - if ( isset( $_REQUEST['l'] ) ) { - $l = sanitize_text_field( $_REQUEST['l'] ); - } else { - $l = false; - } + $filters = pmpro_sanitize_memberships_list_filters( $_REQUEST ); if(isset($_REQUEST['s'])) $s = trim( sanitize_text_field( $_REQUEST['s'] ) ); else $s = ""; ?>

    - - - + + +


    pmpro_memberships_users mu2 WHERE mu2.user_id = u.ID AND mu2.status = 'active') "; - } - - if ( 'oldmembers' === $l ) { - $sqlQuery .= " AND mu.status <> 'active' "; - } elseif ( 'expired' === $l ) { - $sqlQuery .= " AND mu.status = 'expired' "; - } elseif ( 'cancelled' === $l ) { - $sqlQuery .= " AND mu.status IN('cancelled', 'admin_cancelled') "; - } elseif ( $l ) { - $sqlQuery .= " AND mu.status = 'active' AND mu.membership_id = '" . (int) $l . "' "; - } else { - $sqlQuery .= " AND mu.status = 'active' "; - } + // Filter by level, status, and whether to hide memberships for users who are active again. + $sqlQuery .= pmpro_memberships_list_filter_sql( $filters ); if ( ! $count ) { $sqlQuery .= ' GROUP BY u.ID, mu.membership_id '; @@ -765,8 +739,9 @@ public function column_startdate( $item ) { * @return string Text to be placed inside the column . */ public function column_enddate( $item ) { - if ( isset( $_REQUEST['l'] ) && ! empty( pmpro_sanitize_with_safelist( $_REQUEST['l'] , array( 'oldmembers', 'expired', 'cancelled' ) ) ) ) { - // If viewing removed levels, show the end date for the membership that was removed. + $filters = pmpro_sanitize_memberships_list_filters( $_REQUEST ); + if ( 'active' !== $filters['status'] ) { + // If viewing ended memberships, show the end date for the membership that was removed. return date_i18n( get_option( 'date_format' ), $item['enddate'] ); } @@ -779,30 +754,109 @@ public function column_enddate( $item ) { * @param string $which, helps you decide if you add the markup after (bottom) or before (top) the list array( '' => 'Select a Level' ) */ function extra_tablenav( $which ) { - global $membership_levels, $wpdb; if ( $which == 'top' ) { // The code that goes before the table is here - if(isset($_REQUEST['l'])) { - $l = sanitize_text_field($_REQUEST['l']); - } else { - $l = false; + $filters = pmpro_sanitize_memberships_list_filters( $_REQUEST ); + + // Count active filters for the toggle button badge. + $active_filter_count = 0; + if ( '' !== $filters['l'] ) { + $active_filter_count++; } - esc_html_e('Show', 'paid-memberships-pro' );?> - + if ( 'active' !== $filters['status'] ) { + $active_filter_count++; + } + if ( 'active' !== $filters['status'] && 'anylevel' === $filters['excludeactive'] ) { + $active_filter_count++; + } + + // Prepare data for filter value selectors. + $levels = pmpro_sort_levels_by_order( pmpro_getAllLevels( true, true ) ); + ?> + + + 0 ) { ?> + + + +
    +
    +
    +

    + +
    + +
    + +
    + + +
    + + +
    + + +
    + + +
    > + + +
    + +
    + +
    + + 0 ) { ?> + + +
    +
    +
    + + add_menu( + $wp_admin_bar->add_menu( array( 'id' => 'pmpro-members-list', 'parent' => 'paid-memberships-pro', - 'title' => __( 'Members', 'paid-memberships-pro' ), + 'title' => __( 'Memberships', 'paid-memberships-pro' ), 'href' => admin_url( 'admin.php?page=pmpro-memberslist' ) ) ); diff --git a/includes/functions.php b/includes/functions.php index ad95249696..3f823a8764 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -5520,3 +5520,146 @@ function pmpro_update_post_level_restrictions( $post_id, $level_ids ) { do_action( 'pmpro_after_updating_post_level_restrictions', $post_id ); } } + +/** + * Get the membership statuses that represent an "ended" membership. + * + * These are the statuses that a row in the memberships users table can + * end up in once a user's membership at that level is over. Statuses used + * for incomplete checkouts (e.g. 'token', 'pending', 'review', 'error') + * are intentionally not included. + * + * @since TBD + * + * @return string[] Membership statuses considered "ended". + */ +function pmpro_get_ended_membership_statuses() { + $statuses = array( 'expired', 'cancelled', 'admin_cancelled', 'changed', 'admin_changed', 'inactive' ); + + /** + * Filter the membership statuses that are considered "ended" when + * filtering the Memberships list and the members CSV export. + * + * @since TBD + * + * @param string[] $statuses Membership statuses considered "ended". + */ + return apply_filters( 'pmpro_ended_membership_statuses', $statuses ); +} + +/** + * Sanitize and normalize the filters used by the Memberships list and the members CSV export. + * + * Legacy values of the `l` parameter ('cancelled', 'expired', 'oldmembers') are + * mapped to the equivalent `status` and `excludeactive` filters so that older + * links to the Memberships list continue to work with their original meaning. + * + * @since TBD + * + * @param array $args Raw query args, e.g. $_REQUEST or REST request params. + * @return array { + * Normalized filters. + * + * @type string $l Level ID to filter by, or '' for all levels. + * @type string $status One of 'active', 'cancelled', 'expired', or 'ended'. + * @type string $excludeactive Which ended memberships to hide: 'samelevel' to hide rows where + * the user has since re-activated the same level, or 'anylevel' to + * hide rows for users with any active membership. + * } + */ +function pmpro_sanitize_memberships_list_filters( $args ) { + $l = isset( $args['l'] ) ? sanitize_text_field( $args['l'] ) : ''; + $status = isset( $args['status'] ) ? sanitize_text_field( $args['status'] ) : ''; + $excludeactive = isset( $args['excludeactive'] ) ? sanitize_text_field( $args['excludeactive'] ) : ''; + + // Map legacy values of the l parameter to the status and excludeactive filters. + // These older views only showed users with no active membership at any level. + if ( in_array( $l, array( 'cancelled', 'expired', 'oldmembers' ), true ) ) { + $status = 'oldmembers' === $l ? 'ended' : $l; + $excludeactive = 'anylevel'; + $l = ''; + } + + if ( ! in_array( $status, array( 'active', 'cancelled', 'expired', 'ended' ), true ) ) { + $status = 'active'; + } + + if ( ! in_array( $excludeactive, array( 'samelevel', 'anylevel' ), true ) ) { + $excludeactive = 'samelevel'; + } + + if ( ! is_numeric( $l ) || (int) $l <= 0 ) { + $l = ''; + } + + return array( + 'l' => $l, + 'status' => $status, + 'excludeactive' => $excludeactive, + ); +} + +/** + * Build the level and status WHERE fragment for Memberships list queries. + * + * Used by both the Memberships list table and the members CSV export so that + * the exported rows always match the rows shown on screen. The query being + * built must alias the users table as `u` and the memberships users table as `mu`. + * + * When viewing ended memberships, rows where the user has since re-activated + * the same level are always hidden: the list shows the current state of each + * user's relationship with a level, and that relationship is active again. + * Setting `excludeactive` to 'anylevel' additionally hides rows for users who + * have an active membership at any level. + * + * @since TBD + * + * @param array $filters Filters as returned by pmpro_sanitize_memberships_list_filters(). + * @return string SQL fragment beginning with ' AND '. + */ +function pmpro_memberships_list_filter_sql( $filters ) { + global $wpdb; + + $filters = wp_parse_args( + $filters, + array( + 'l' => '', + 'status' => 'active', + 'excludeactive' => 'samelevel', + ) + ); + + $sql = ''; + + // Filter by level. + if ( is_numeric( $filters['l'] ) && (int) $filters['l'] > 0 ) { + $sql .= ' AND mu.membership_id = ' . (int) $filters['l'] . ' '; + } + + // Filter by status. + switch ( $filters['status'] ) { + case 'cancelled': + $statuses = array( 'cancelled', 'admin_cancelled' ); + break; + case 'expired': + $statuses = array( 'expired' ); + break; + case 'ended': + $statuses = pmpro_get_ended_membership_statuses(); + break; + default: + $statuses = array( 'active' ); + } + $sql .= " AND mu.status IN('" . implode( "','", array_map( 'esc_sql', $statuses ) ) . "') "; + + // When viewing ended memberships, hide rows for users who are active again. + if ( 'active' !== $filters['status'] ) { + if ( 'anylevel' === $filters['excludeactive'] ) { + $sql .= " AND NOT EXISTS ( SELECT 1 FROM $wpdb->pmpro_memberships_users mu2 WHERE mu2.user_id = u.ID AND mu2.status = 'active' ) "; + } else { + $sql .= " AND NOT EXISTS ( SELECT 1 FROM $wpdb->pmpro_memberships_users mu2 WHERE mu2.user_id = u.ID AND mu2.membership_id = mu.membership_id AND mu2.status = 'active' ) "; + } + } + + return $sql; +}