feat(access-control): human-readable group subscription names#4667
feat(access-control): human-readable group subscription names#4667
Conversation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…n list column Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ist view Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…s with members Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…n filter Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds support for a human-readable “Group subscription name” on WooCommerce Subscriptions, and enhances the subscriptions admin list to display/filter/search using that group context.
Changes:
- Adds a
namesetting to group subscription settings, including admin edit UI and save handling. - Customizes the Subscriptions list “Subscription” column for group subscriptions and adds a group/non-group filter.
- Extends subscription search to include the group name meta key, and adds unit tests for name behavior.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 7 comments.
| File | Description |
|---|---|
| includes/plugins/woocommerce-subscriptions/group-subscription/class-group-subscription-settings.php | Adds group name setting, list-table display/filter/search hooks, and query helpers for group subscriptions. |
| tests/unit-tests/content-gate/group-subscriptions.php | Adds unit tests covering default/custom/updated/empty-fallback group name behavior. |
| tests/mocks/wc-mocks.php | Extends the WC_Subscription test mock with get_formatted_billing_full_name() used by the new default-name logic. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…n admin - Fix search WHERE clause regex to handle nested parentheses - Scope order items query to shop_subscription type only - Cache group subscription IDs in a 5-minute transient - Invalidate cache on subscription and product setting changes Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…on filtering Replace pre_get_posts with the request filter for CPT-mode subscription list tables. This matches the approach WCS uses for its own filters and works reliably across WooCommerce versions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…queries Replace wc_get_orders (which ignores meta_query in CPT mode) with wcs_get_subscriptions and wcs_get_subscriptions_for_product, which properly handle meta queries in both CPT and HPOS storage modes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ion filter Variations have their own independent group subscription settings and do not inherit from the parent variable product. Skip variable parent products when enumerating subscriptions via wcs_get_subscriptions_for_product to prevent incorrectly matching subscriptions whose variations aren't group-enabled. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
A subscription's own _newspack_group_subscription_enabled = 'no' meta should override the product-level 'yes' setting. Remove explicitly opted-out subscriptions from the product inheritance path. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Note: pushed fixes to handle some edge cases:
|
adekbadek
left a comment
There was a problem hiding this comment.
Tested in an isolated env (CPT and HPOS) — column rendering, filter dropdown (Group / Non-group / Reset), and group-name search all behave per the test plan. Precedence works end-to-end: a subscription with _newspack_group_subscription_enabled = no against a group-enabled product is correctly excluded from Group subscriptions; subscriptions inheriting from the product are correctly included. Default-name fallback (<owner>’s Group) renders, custom names persist and are searchable. CI green on the latest commit. Outcome: comments only — no blockers.
Automated reviewers used: deep-review (Opus) and standards-review (superpowers:code-reviewer). Codex was unavailable (CLI not installed locally) so no third opinion this round.
Most worth addressing before merge:
- Perf scaling of
get_group_subscription_ids()— three full-table scans withposts_per_page => -1per cache miss. Inline note on the function. - Cache invalidation — busts on every subscription save (because the meta box always posts
enabled), and there is no hook for trash/delete. Inline note. - Regex rewrite of WP's search WHERE — fragile and silently no-ops on shape changes; also untested.
- Test coverage — the four new tests cover the
namesetting only; the precedence rules and search injection (the most subtle code in the diff) are not exercised. wp_kses_post( woocommerce_wp_text_input(...) )—woocommerce_wp_text_input()echoes and returnsnull, so the kses wrapper is a no-op. On PHP 8.1+ it triggerspreg_replace(): Passing null to parameter #3 ... deprecated— I observed this live indebug.logwhile the meta box was rendering. Pre-existing pattern in this file, but the PR extends it.
Rest is polish (a11y label on the filter dropdown, missing cache invalidation on subscription delete, the column markup replacing rather than appending, brittle $_GET['page'] guard for HPOS search, DRY of the two near-identical filter methods, and a few nits) — all in inline notes.
Verified browser screenshots and a screen recording are saved locally; happy to attach if useful.
| * | ||
| * @return int[] Array of subscription IDs. | ||
| */ | ||
| public static function get_group_subscription_ids() { |
There was a problem hiding this comment.
Perf scaling. Each cache miss runs three queries with posts_per_page => -1 and subscription_status => 'any': subs with enabled=yes, subs with enabled=no, and all product/product_variation posts with enabled=yes, then loops wcs_get_subscriptions_for_product() per product. On a publisher with thousands of subscriptions/products this stalls the admin list. Consider building the ID list in a single SQL/joined-meta query, or skipping the full ID list for non-group mode and using meta_query with compare = NOT EXISTS / value = no instead.
(Combined with the cache-invalidation note below, this is the most worth-fixing finding — every save invalidates the cache, so the cold path is hit constantly.)
There was a problem hiding this comment.
Deferring this for now. Combined with the cache-invalidation fix in e981408 (which addresses the cache being busted on every subscription save), the 5-minute transient should hold for the full TTL on most installs and the cold path becomes much rarer.
Re-architecting around a single joined query would mean dropping the WCS API helpers (wcs_get_subscriptions, wcs_get_subscriptions_for_product) which are useful for correctness across CPT/HPOS modes. The NOT EXISTS shortcut for non-group mode also doesn't cover the product-inheritance + subscription-level opt-out cases cleanly.
Will track this as a follow-up if it shows up as an actual bottleneck on a publisher with many group subscriptions.
| $subscription->save(); | ||
|
|
||
| // Clear the cached group subscription IDs if the enabled setting changed. | ||
| if ( isset( $settings['enabled'] ) ) { |
There was a problem hiding this comment.
Cache invalidation is too eager. The meta box always posts _newspack_group_subscription_enabled (whether checked or not), so isset( $settings['enabled'] ) is always true on subscription save. The transient is busted on every save, even when the value didn't change — defeating the 5-min TTL and exposing the perf cost in get_group_subscription_ids() above.
Clear only when the value actually changed (compare against $previous_settings['enabled']).
There was a problem hiding this comment.
Fixed in e981408. Two bugs uncovered while tracing this:
update_subscription_settings()was comparingwc_bool_to_string($value)('yes'/'no') against$previous_settings[$key](bool), so for theenabledkey the strict comparison was always true and$should_saveflipped on every save — even when nothing changed.- The cache-clear condition was
isset( $settings['enabled'] ), which was always true since the meta box always passes the key.
Now we track which keys actually changed (after normalizing both sides of the comparison) and only invalidate the cache if enabled is among them.
| \add_filter( 'request', [ __CLASS__, 'filter_subscriptions_by_group_cpt' ] ); | ||
|
|
||
| // Clear group subscription IDs cache when product group settings change. | ||
| \add_action( 'woocommerce_process_product_meta', [ __CLASS__, 'maybe_clear_cache_on_product_save' ] ); |
There was a problem hiding this comment.
Missing cache invalidation on subscription delete/trash. When a group subscription is trashed/deleted, the cached ID list lingers for up to 5 minutes — Group subscriptions will still match against IDs that no longer exist. Consider adding wp_trash_post / before_delete_post hooks (gated on 'shop_subscription' === get_post_type( $post_id )) to clear the transient.
There was a problem hiding this comment.
Fixed in e981408. Added four hooks to cover both storage modes:
wp_trash_postandbefore_delete_post(CPT) — gated on'shop_subscription' === get_post_type( $post_id )woocommerce_trash_subscriptionandwoocommerce_delete_subscription(HPOS)
|
|
||
| // Insert the OR clause inside the existing grouped search condition. | ||
| // WP's search clause can end with )) or ) depending on search terms. | ||
| if ( preg_match( '/\)\)\s*$/', $search ) ) { |
There was a problem hiding this comment.
Fragile search rewrite. WP_Query::parse_search() produces clauses shaped differently with exact, sentence, multi-term, or other plugins' posts_search filters running before this one — the exact bracketing has shifted between WP versions. When neither regex matches, preg_replace silently returns the original $search unchanged: search-by-group-name fails quietly with no signal.
Safer alternatives:
- Wrap the original clause from outside:
( {$search} OR ( np_group_name.meta_value LIKE %s ) ). - Or run a separate ID lookup and inject via
pre_get_postspost__in.
Also worth covering this code path with a unit test — it's the trickiest piece in the diff and currently has zero coverage.
There was a problem hiding this comment.
Fixed in e981408. Switched to wrapping the existing clause from outside:
$inner = preg_replace( '/^\s*AND\s+/', '', $search );
$search = " AND ( {$inner} OR {$or_clause} ) ";This is robust against exact/sentence/multi-term and other plugins' posts_search filters running before ours, since we don't rely on the inner shape.
Also added five unit tests covering: non-subscription queries (skip), empty search (skip), simple clause wrapping, multi-term clauses, and special-character escaping.
| sprintf( | ||
| /* translators: %s: The subscription owner's name. */ | ||
| __( '%s Group', 'newspack-plugin' ), | ||
| $owner_name ? $owner_name . '’s' : __( 'Unnamed', 'newspack-plugin' ) |
There was a problem hiding this comment.
i18n nit on the unnamed-owner fallback.
With a billing name → "Jane Doe’s Group". With no billing name → "Unnamed Group" (no ’s). The asymmetry is harmless, but composing the phrase in PHP via sprintf( '%s Group', ... ) means translators can't reorder X's Group → Group de X in other locales — only the trailing word can vary.
Consider making the entire phrases translatable instead, e.g.:
$settings['name'] = $owner_name
? sprintf( __( '%s’s Group', 'newspack-plugin' ), $owner_name )
: __( 'Unnamed group', 'newspack-plugin' );Minor: the meta is fetched twice on the previous two lines — assigning to a local first would help readability.
There was a problem hiding this comment.
Fixed in e981408. Made the whole phrases translatable per your suggestion:
if ( $name_meta ) {
$settings['name'] = $name_meta;
} elseif ( $owner_name ) {
$settings['name'] = sprintf( __( '%s’s Group', 'newspack-plugin' ), $owner_name );
} else {
$settings['name'] = __( 'Unnamed group', 'newspack-plugin' );
}Also fetching each meta value into a local once instead of twice.
| $selected = isset( $_GET['_newspack_group_subscription'] ) ? \sanitize_text_field( \wp_unslash( $_GET['_newspack_group_subscription'] ) ) : ''; | ||
|
|
||
| ?> | ||
| <select name="_newspack_group_subscription" id="_newspack_group_subscription"> |
There was a problem hiding this comment.
a11y: filter dropdown is missing an accessible label. Other WC filters in the same toolbar use <label class="screen-reader-text" for="…">. Worth matching here for parity (and screen-reader users):
printf( '<label class="screen-reader-text" for="_newspack_group_subscription">%s</label>', esc_html__( 'Filter by group subscription status', 'newspack-plugin' ) );There was a problem hiding this comment.
Fixed in 24bfee4 — added the <label class="screen-reader-text"> for the dropdown to match the pattern used by other WC filters in the toolbar.
| } | ||
| // phpcs:ignore WordPress.Security.NonceVerification.Recommended | ||
| $page = isset( $_GET['page'] ) ? \sanitize_text_field( \wp_unslash( $_GET['page'] ) ) : ''; | ||
| if ( 'wc-orders--shop_subscription' !== $page ) { |
There was a problem hiding this comment.
HPOS guard is brittle. Coupling to the literal page slug wc-orders--shop_subscription means a future WC slug change silently disables group-name search — and AJAX/REST contexts that don't carry page in the query string skip the filter entirely. Worth at least a // @TODO replace with a stabler signal if WC exposes one. comment, or hooking off current_screen instead.
There was a problem hiding this comment.
Fixed in e50cf8f. Now uses get_current_screen() with wcs_get_page_screen_id( 'shop_subscription' ) — same pattern as admin_enqueue_scripts in this file. Returns the meta keys unchanged in AJAX/REST contexts (where get_current_screen() is null), which is the correct no-op since those aren't subscription list-table searches.
| * | ||
| * @return array The filtered query args. | ||
| */ | ||
| public static function filter_subscriptions_by_group( $query_args ) { |
There was a problem hiding this comment.
DRY. filter_subscriptions_by_group() and filter_subscriptions_by_group_cpt() are ~30 lines of mirrored post__in/post__not_in logic — only the early guard differs. Extract a private helper that takes array $args and returns the mutated args; both filters call into it.
There was a problem hiding this comment.
Fixed in e50cf8f. Extracted a private apply_group_filter( $args, $filter, $group_ids ) helper. Both filter_subscriptions_by_group() (HPOS) and filter_subscriptions_by_group_cpt() (CPT) keep their distinct entry guards and call into the shared helper for the merge logic.
| $this->assertEmpty( $result ); | ||
| } | ||
|
|
||
| // ------------------------------------------------------------------------- |
There was a problem hiding this comment.
Test coverage gap. The four new tests exercise the name setting (default fallback / custom override / update-and-read-back / empty fallback). None of the substantively new behavior is covered:
- The filter precedence rules: subscription
yesoverrides product, subscriptionnooverrides product inheritance, variable parents are skipped, etc. get_group_subscription_ids()itself.- Cache invalidation paths.
- Search injection.
Per the project's TDD/spec philosophy, the precedence logic in particular is the highest-value addition — it's the most subtle code in this PR and the most likely to regress quietly.
There was a problem hiding this comment.
Partially addressed in e50cf8f — added two tests covering the transient cache hit path and clear_group_subscription_ids_cache() invalidation.
The full precedence-rule tests (subscription yes overrides product, no overrides product inheritance, variable parents skipped, etc.) require real shop_subscription and product posts in the test database, since wcs_get_subscriptions() and wcs_get_subscriptions_for_product() query the actual posts/order tables. Our current tests/mocks/wc-mocks.php stores subscriptions in a $subscriptions_database global that those WCS functions don't see.
I'll open a follow-up issue to build out the integration test infrastructure needed for end-to-end coverage of get_group_subscription_ids(). Agreed it's the most subtle code in this PR and deserves real coverage.
| $last = $this->data['billing_last_name'] ?? ''; | ||
| $name = trim( "$first $last" ); | ||
| return $name ? $name : ''; | ||
| } |
There was a problem hiding this comment.
Nit: the ternary is a no-op. trim( '' ) already returns '', so return $name ? $name : ''; simplifies to return $name; (or just return trim( "$first $last" ); directly).
There was a problem hiding this comment.
Fixed in e50cf8f. Simplified to return trim( "$first $last" ); — the ternary was a workaround for a phpcs rule against short ternaries (?:) but isn't needed at all since trim('') already returns ''.
- Remove wp_kses_post() wrapper around woocommerce_wp_text_input(), which echoes and returns null. The wrapper was triggering PHP 8.1+ deprecation warnings. - Fix cache invalidation eagerness: track which keys actually changed and only clear the group subscription IDs cache when 'enabled' changed. Also fix a pre-existing bool comparison bug that caused spurious meta saves on every settings update. - Add cache invalidation hooks for subscription trash/delete in both CPT (wp_trash_post, before_delete_post) and HPOS modes (woocommerce_trash_subscription, woocommerce_delete_subscription). - Make search WHERE clause rewrite robust against varied inner shapes (exact/sentence/multi-term, other plugins' posts_search filters). Wrap the existing clause from outside instead of pattern-matching the trailing parens. - Add unit tests for search_group_name_where(). - Improve i18n of default group name: make whole phrases translatable (%s's Group / Unnamed group) so translators can reorder for non-English grammars. Also fetch each meta value once instead of twice. - Column markup now prepends group info before the standard WCS column content rather than replacing it, preserving status pills and any future WCS additions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… input Same null-return pattern as the one fixed for the new group name input — woocommerce_wp_text_input() echoes and returns void/null, so wrapping it in wp_kses_post() triggers a PHP 8.1+ deprecation warning. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- HPOS search-field guard: replace brittle page-slug check with get_current_screen() + wcs_get_page_screen_id(), which falls back gracefully in AJAX/REST contexts and survives WC slug changes. - DRY: extract shared post__in/post__not_in merge logic into a private apply_group_filter() helper used by both the HPOS and CPT filter callbacks. - Add unit tests covering the get_group_subscription_ids() transient cache hit path and clear_group_subscription_ids_cache() invalidation. (Full integration tests for the precedence rules are tracked as a follow-up; they require real shop_subscription/product posts and more test infrastructure than the current mocks support.) - Simplify the WC_Subscription mock's get_formatted_billing_full_name() by removing the redundant ternary on a trim() result. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
@adekbadek thanks for the review and feedback! I've pushed some changes to address your comments, so this is ready for a re-review. |
Summary
Adds an editable field to set human-readable names for Group subscriptions (which defaults to
<subscription owner>'s Group), and adds admin UX enhancements for the WooCommerce > Subscriptions list view:Subscription edit page:

Subscriptions list view:

<group name> (<x> of <y> members)instead of the standard#<id> for <name>format, withyshowing the member limit or "unlimited" if no limit is set.WP_Querysearches.Group filter precedence
The filter respects the same precedence as
get_subscription_settings():enabled = yes→ included (explicit opt-in)enabled = no→ excluded (explicit opt-out overrides product)enabled = yes→ included (inheritance)Variable parent products are skipped in the inheritance path since variations have their own independent group settings.
Performance
newspack_group_subscription_ids)wcs_get_subscriptions,wcs_get_subscriptions_for_product) which properly handle meta queries in both CPT and HPOS storage modesTest plan
Prerequisites
Subscription column
Group filter
Search
Unit tests
n test-php --filter Test_Group_Subscriptionsand verify all 49 tests pass🤖 Generated with Claude Code