Skip to content

Newsletter: Subscribers DataViews page in wp-admin (port from Calypso) #48365

@keoshi

Description

@keoshi

Goal

Port the Calypso Subscribers page (https://cloud.jetpack.com/subscribers/) into Jetpack's wp-admin so that Jetpack-connected sites can manage their subscribers without leaving wp-admin. The implementation should reach 1:1 parity with the Calypso UI before we consider any deviations.

The feature already has an empty home in the monorepo: projects/packages/subscribers-dashboard/. Today the package owns a top-level "Subscribers" wp-admin menu (gated behind the jetpack_wp_admin_subscriber_management_enabled filter, default false) and renders a <b>Hello world!</b> stub. The wiring is in place — menu registration, asset enqueue, build pipeline, and @wordpress/dataviews@14.1.0 is already a dep — we just need to fill it in.

When the page is available in wp-admin, the existing "Subscribers ↗" external link to cloud.jetpack.com (in projects/plugins/jetpack/modules/subscriptions.php) is suppressed by the same filter, so we replace the link with the in-admin page once the filter is flipped.

Reference

  • Calypso source of truth: client/my-sites/subscribers/
  • Stub package: projects/packages/subscribers-dashboard/
  • Filter that gates it: jetpack_wp_admin_subscriber_management_enabled
  • Sample API response shapeGET /wpcom/v2/sites/{site_id}/subscribers?per_page=10&page=1&sort=date_subscribed&sort_order=desc&use_new_helper=true&filters[]=all:
{
  "total": 545,
  "pages": 55,
  "page": 1,
  "per_page": 10,
  "subscribers": [
    {
      "user_id": 41571258,
      "wpcom_subscription_id": 833368396,
      "wpcom_date_subscribed": "2026-03-10 10:56:44",
      "subscription_status": "Subscribed",
      "email_subscription_id": 937745224,
      "email_date_subscribed": "2026-03-10 10:56:44",
      "email_address": "redacted@example.com",
      "display_name": "Display Name",
      "avatar": "https://0.gravatar.com/avatar/…"
    }
  ],
  "is_owner_subscribed": true
}

Architecture decisions

  1. Where it lives — replace the stub inside projects/packages/subscribers-dashboard/_inc/. The package already owns the menu, asset wiring, and build pipeline. Newsletter settings stays as-is; we'll add a "Manage subscribers" link to this page from there.
  2. Data source — add a Jetpack REST proxy endpoint (e.g. GET /wpcom/v2/jetpack-subscribers/list) that forwards to GET /wpcom/v2/sites/{site_id}/subscribers server-side using the blog token. Avoids exposing the user's WP.com token client-side and mirrors how other Jetpack features fetch from WP.com. Mutations (remove, cancel paid subscription, etc.) follow the same proxy pattern.
  3. State / data layer — React Query (already used in Calypso); pagination, filters, search, sort all live in URL search params (mirroring the Forms package view-state pattern in this repo).
  4. DataViews@wordpress/dataviews server-driven mode (paginationInfo from API response, server-side sort and filters via query params).
  5. Auth / target — Jetpack-connected self-hosted is the primary target. Atomic / Simple already get a Calypso-backed equivalent via nav-unification; for those hosts we keep the existing behavior unless the filter is explicitly flipped on.

Phases

Each phase ships as its own PR, stacked on a shared integration branch (try/newsletter-subscribers-dataviews).

Phase 1 — Skeleton ✅ (commit 073a6a4)

  • Replace _inc/admin.jsx with a real React app shell: layout, header, container. (Note: apiFetch over a thin useState/useEffect hook for now; React Query lands in Phase 4 alongside mutations.)
  • Add Jetpack REST proxy endpoint GET /wpcom/v2/subscribers/list (forwards page, per_page, sort, sort_order, filters[], search, use_new_helper=true).
  • Minimal DataViews wiring: 3 columns — avatar, name (display_name + email_address stacked), date subscribed. Server-side pagination via page/per_page. Server-side sort by date_subscribed. No filters, no row actions yet.
  • Loading and error states (empty-state polish moved to Phase 5 alongside Calypso parity copy).
  • TypeScript types for the API response (mirror the JSON shape above).

Phase 2 — Field parity ✅ (commit af34112)

  • All Calypso columns: media (avatar), name (display name + email), subscription type (plan/Comp/Free/Paid), email subscription status, date subscribed.
  • Identity column custom render (gravatar + name + email stacked, matching Calypso).
  • Sort enabled on name, plan, subscription_status, date_subscribed (matches Calypso SubscribersSortBy enum).
  • Default sort: date_subscribed descending.
  • Subscriber totals summary in the page header via new GET /wpcom/v2/subscribers/totals proxy → /wpcom/v2/sites/{id}/subscribers/counts. (Filtered/searched variants land in Phase 3.)
  • Bundle @wordpress/dataviews stylesheet so the table chrome renders.

Phase 3 — Filters & search ✅ (commit f61c28c)

  • Filter chips for All / Paid / Comp / Free (subscription type field) and Subscribed / Not subscribed / Not confirmed / Not sending → email_subscriber / reader_subscriber / unconfirmed_subscriber / blocked_subscriber (subscription_status field). Each value maps to Calypso's filters[]=….
  • Free-text search via DataViews' built-in search input → API search param.
  • Persist view (page, perPage, sort, search, filters) in URL search params; ships as a small useViewState() hook. (We're not using the Forms-package context router because the subscribers-dashboard package is standalone — the hook reads/writes via URLSearchParams + history.replaceState directly. wp-admin's page=subscribers arg is preserved.)

Phase 4 — Row + bulk actions ✅ (commit b390ca3)

  • Per-row + bulk Remove subscriber action (supportsBulk: true, capped at 100 like Calypso).
  • Confirmation modal explains the cascade (paid sub cancel + WPCOM follower delete + email follower delete).
  • Cascade runs server-side via new POST /wpcom/v2/subscribers/remove proxy that calls Client::wpcom_json_api_request_as_user for each underlying WP.com endpoint; collects per-step errors so partial failures surface.
  • React Query for queries (list + totals) + mutation. Cache invalidation on success via queryClient.invalidateQueries so the table and totals refetch.
  • Success / failure feedback via @wordpress/notices SnackbarList.
  • Comp a subscription — deferred to Phase 5c.
  • Remove comp — deferred to Phase 5c.
  • View — landed in Phase 6 (commit 24dd335).

Phase 5 — Header CTAs + empty state ✅ (commit 958cd2d)

  • Add subscribers primary CTA in the header. Modal supports manual email entry (forgiving parser, inline invalid-entry warning); POSTs to new POST /wpcom/v2/subscribers/add proxy → /sites/{id}/invites/new.
  • More menu: Download as CSV (links to wpcom's existing export endpoint).
  • Empty state: icon + title + body + CTA when totalItems === 0 and no filters/search. Filtered-empty stays on DataViews' built-in empty UI.
  • Responsive header row collapses on narrow viewports.

Phase 5b — Importers + a11y/RTL ✅ (commits c330f92 + follow-ups)

  • Upload CSV tab in the Add Subscribers modal. Client-side parser handles Substack / Beehiiv / Mailchimp / Ghost / Patreon / Kit / Medium exports — emails feed into the existing /subscribers/add proxy. (Multipart pass-through to wpcom's /subscribers/import endpoint stays a follow-up; the JSON path covers the realistic single-column-of-emails case.)
  • Substack tab links out to Calypso's guided importer wizard at wordpress.com/import/newsletter/substack/{hostname} — same hand-off Calypso itself uses.
  • a11y polish: dropped duplicated avatar alt; verified focus + contrast on Badges + Dialog/AlertDialog focus-trap.
  • RTL pass: verified in browser — sidebar mirrors, table columns reverse, inspector slot moves to the left, stat-card row reverses, pagination chevrons mirror. No code changes needed (all our padding is shorthand or symmetric, Stack flexbox direction reverses naturally).
  • Migrate subscribers from another WPCOM site — still deferred. Calypso's useMigrateSubscribersCallback POSTs to /jetpack-blogs/{target}/source/{source}/migrate, but the source-site picker needs /me/sites which currently returns "An active access token must be used to query information about the current user" when called via Client::wpcom_json_api_request_as_user. The in-modal flow was prototyped and reverted (commit faa3a11afe); we'll bring it back once we have a path to authenticate the /me/sites lookup from inside the dashboard.

Phase 5c — Comp / Remove comp ✅ (commit f0544b2)

  • Per-row Comp a subscription modal — plan picker (SelectControl) over the site's membership products + "Doesn't expire" checkbox, mirrors Calypso's <CompModal>.
  • Per-row Remove comp AlertDialog confirm; takes comp_id from the row's plans[] (is_comp && comp_id).
  • Three new REST proxies: GET /wpcom/v2/subscribers/products (memberships products feeding the picker), POST /wpcom/v2/subscribers/comp (calls /sites/{id}/memberships/comps/{user_id}/{plan_id} with optional no_expiration), POST /wpcom/v2/subscribers/remove-comp (forwards as DELETE to /sites/{id}/memberships/comp/{compId}).
  • Eligibility checks mirror Calypso: Comp shows when the row has a user_id; Remove comp shows when at least one plan is is_comp with a comp_id.

Phase 6 — Subscriber detail modal + stats ✅ (commit 24dd335)

  • Per-row View action opens a SubscriberDetailModal (modal rather than slide-in panel — simpler + accessible out of the box).
  • useSubscriberDetails + useSubscriberStats React Query hooks.
  • Two new REST proxies: GET /wpcom/v2/subscribers/individual/sites/{id}/subscribers/individual; GET /wpcom/v2/subscribers/stats/sites/{id}/individual-subscriber-stats. Both accept either subscription_id or user_id.
  • Deep-linkable URL: ?subscriber={subscription_id}&u={user_id} via a small useOpenSubscriber() hook that hydrates from the URL on mount and writes back via history.replaceState.
  • Skeleton spinner while details + stats are in flight.

Phase 7 — Telemetry ✅ (commit abd99cd)

  • Mirror Calypso's tracks events with a jetpack_subscribers_* namespace via @automattic/jetpack-analytics:
    • search_performed / filter_applied / sort_changed (DataViews onChangeView diff)
    • subscriber_row_clicked
    • subscriber_removed (per row, after API success)
    • remove_{free|paid}_subscriber_modal_{showed,dismissed}
    • comp_modal_open, comp_modal_confirm, remove_comp_confirm
    • export_downloaded
    • add_question (Add Subscribers tab change, with method prop)
    • empty_view_displayed
  • Migrate-related events deferred along with the Migrate flow itself.

Out of scope (for now)

  • Switching the default value of jetpack_wp_admin_subscriber_management_enabled to true. We'll keep it false until the implementation is feature-complete and reviewed; flipping the default is a follow-up issue once Phases 1-5 are merged.
  • Replacing the existing Atomic / Simple "Subscribers" nav-unification entries (those continue to point at Calypso for now).
  • Memberships / paid newsletter settings management (already lives in Newsletter package).

Calypso reference summary

Concern Calypso source
Top-level page client/my-sites/subscribers/main.tsx
DataViews wiring client/my-sites/subscribers/components/subscriber-data-views.tsx
Sort enum client/my-sites/subscribers/constants.ts (SubscribersSortBy)
Filter values client/my-sites/subscribers/constants.ts (SubscribersFilterBy)
List query client/my-sites/subscribers/queries/use-subscribers-query.tsx
Counts query client/my-sites/subscribers/queries/use-subscriber-count-query.ts
Remove mutation client/my-sites/subscribers/mutations/use-subscriber-remove-mutation.ts
Tracks events client/my-sites/subscribers/tracks/
Helpers (cache keys, ID resolution) client/my-sites/subscribers/helpers/index.ts

Notes for reviewers / contributors

  • The dual identity model (user_id 0 means email-only, non-zero means WPCOM user) is the trickiest part. The remove flow has to call different endpoints based on which IDs are present. See getSubscriberDetailsType() and getSubscriptionIdFromSubscriber() in helpers/index.ts.
  • The API exposes both wpcom_date_subscribed and email_date_subscribed; Calypso uses wpcom_date_subscribed when present, otherwise falls back to email_date_subscribed. Same logic should be ported.
  • Keep the React component layout close to Calypso so future deltas can be diffed easily.

Metadata

Metadata

Assignees

No one assigned

    Labels

    EpicFormerly "Primary Issue", or "Master Issue"[Feature] SubscriptionsAll subscription-related things such as paid and unpaid, user management, and newsletter settings.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions