Skip to content

Forms: Add per-status form counts endpoint and dashboard integration#47475

Open
enejb wants to merge 1 commit intotrunkfrom
add/form-dashboard-count
Open

Forms: Add per-status form counts endpoint and dashboard integration#47475
enejb wants to merge 1 commit intotrunkfrom
add/form-dashboard-count

Conversation

@enejb
Copy link
Member

@enejb enejb commented Mar 6, 2026

Proposed changes:

  • Add a lightweight REST endpoint (GET /wp/v2/jetpack-forms/status-counts) that returns per-status counts for the jetpack_form post type using wp_count_posts().
  • Replace the previous approach of fetching a minimal entity records query (per_page=1) just for totals with this dedicated endpoint — fewer DB queries, simpler cache story.
  • Display per-status counts inline in the Forms list status filter dropdown labels (e.g. "Published (5)", "Draft (3)").
  • Use the new counts for the header "Forms" tab count instead of relying on totalItems from an entity records query.
  • Invalidate counts automatically on form trash, restore, duplicate, and permanent delete.
  • Preload the endpoint in the dashboard PHP for instant first render.

Other information:

  • Have you written new tests for your changes, if applicable?
  • Have you checked the E2E test CI results, and verified that your changes do not break them?
  • Have you tested your changes on WordPress.com, if applicable (if so, you'll see a generated comment below with a script to run)?

New store pieces:

  • Action types: SET_FORM_STATUS_COUNTS, INVALIDATE_FORM_STATUS_COUNTS
  • Actions: setFormStatusCounts(), invalidateFormStatusCounts()
  • Reducer: formStatusCounts
  • Selector: getFormStatusCounts()
  • Resolver: fetches /wp/v2/jetpack-forms/status-counts, with shouldInvalidate support
  • Hook: useFormStatusCounts() — thin wrapper around the selector with defaults

Does this pull request change what data or activity we track or use?

No.

Testing instructions:

  • Enable the jetpack_forms_alpha flag and the central-form-management editor feature flag.
  • Go to Forms → Forms tab in wp-admin.
  • Verify the status filter dropdown shows counts next to each status label (e.g. "All (10)", "Published (5)", "Trash (2)").
  • Verify the "Forms" header tab shows the correct total count (non-trash forms).
  • Create, duplicate, trash, restore, and permanently delete forms — verify the counts update after each action without a page reload.
  • Check the Network tab: on initial load, the status-counts endpoint should be served from preloaded data (no extra request).

Changelog

  • Generate changelog entries for this PR (using AI).

Add a REST endpoint (`/wp/v2/jetpack-forms/status-counts`) that returns
per-status counts for the jetpack_form post type using wp_count_posts().
Replace the previous approach of fetching a minimal entity records query
just for totals with a dedicated lightweight endpoint.

- Add `get_status_counts()` to Jetpack_Form_Endpoint
- Add store support: action types, actions, reducer, selector, resolver
- Add `useFormStatusCounts` hook for consuming counts in components
- Add `FORM_STATUSES` constant and `getFormStatusLabel('all')` support
- Wire counts into the Forms list status filter labels and header
- Invalidate counts on form trash/restore/duplicate/permanent-delete
- Preload the endpoint in the dashboard for instant first render
- Add tests for constants, store actions/selectors, and resolver invalidation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@enejb enejb added the [Status] Needs Review This PR is ready for review. label Mar 6, 2026
Copilot AI review requested due to automatic review settings March 6, 2026 00:25
@enejb enejb added the [Status] Needs Review This PR is ready for review. label Mar 6, 2026
@github-actions
Copy link
Contributor

github-actions bot commented Mar 6, 2026

Are you an Automattician? Please test your changes on all WordPress.com environments to help mitigate accidental explosions.

  • To test on WoA, go to the Plugins menu on a WoA dev site. Click on the "Upload" button and follow the upgrade flow to be able to upload, install, and activate the Jetpack Beta plugin. Once the plugin is active, go to Jetpack > Jetpack Beta, select your plugin (Jetpack), and enable the add/form-dashboard-count branch.
  • To test on Simple, run the following command on your sandbox:
bin/jetpack-downloader test jetpack add/form-dashboard-count

Interested in more tips and information?

  • In your local development environment, use the jetpack rsync command to sync your changes to a WoA dev blog.
  • Read more about our development workflow here: PCYsg-eg0-p2
  • Figure out when your changes will be shipped to customers here: PCYsg-eg5-p2

@github-actions
Copy link
Contributor

github-actions bot commented Mar 6, 2026

Thank you for your PR!

When contributing to Jetpack, we have a few suggestions that can help us test and review your patch:

  • ✅ Include a description of your PR changes.
  • ✅ Add a "[Status]" label (In Progress, Needs Review, ...).
  • ✅ Add testing instructions.
  • ✅ Specify whether this PR includes any changes to data or privacy.
  • ✅ Add changelog entries to affected projects

This comment will be updated as you work on your PR and make changes. If you think that some of those checks are not needed for your PR, please explain why you think so. Thanks for cooperation 🤖


Follow this PR Review Process:

  1. Ensure all required checks appearing at the bottom of this PR are passing.
  2. Make sure to test your changes on all platforms that it applies to. You're responsible for the quality of the code you ship.
  3. You can use GitHub's Reviewers functionality to request a review.
  4. When it's reviewed and merged, you will be pinged in Slack to deploy the changes to WordPress.com simple once the build is done.

If you have questions about anything, reach out in #jetpack-developers for guidance!

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a dedicated Forms dashboard data flow for per-status jetpack_form counts, replacing the previous “fetch 1 record just to get totals” approach, and integrates those counts into the wp-build UI (header tab count + status filter labels) with cache invalidation and REST preloading.

Changes:

  • Introduces GET /wp/v2/jetpack-forms/status-counts and preloads it in the dashboard to speed up first render.
  • Adds dashboard store state/actions/resolver/selector + a useFormStatusCounts() hook to fetch/cache/invalidate counts.
  • Updates wp-build Forms list UI to display counts in status filters and use counts for the “Forms” tab/header totals.

Reviewed changes

Copilot reviewed 18 out of 18 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
projects/packages/forms/src/contact-form/class-jetpack-form-endpoint.php Adds new REST route and callback to return per-status jetpack_form counts.
projects/packages/forms/src/dashboard/class-dashboard.php Preloads the new status-counts endpoint (plus user-locale variant) for instant client consumption.
projects/packages/forms/src/dashboard/store/action-types.js Adds action types for setting/invalidating form status counts.
projects/packages/forms/src/dashboard/store/actions.js Adds setFormStatusCounts() and invalidateFormStatusCounts() action creators.
projects/packages/forms/src/dashboard/store/reducer.js Adds formStatusCounts reducer slice.
projects/packages/forms/src/dashboard/store/resolvers.js Adds getFormStatusCounts resolver and invalidation wiring.
projects/packages/forms/src/dashboard/store/selectors.js Adds getFormStatusCounts selector.
projects/packages/forms/src/dashboard/hooks/use-form-status-counts.ts New hook to read counts from the dashboard store with defaults.
projects/packages/forms/src/dashboard/hooks/use-delete-form.ts Invalidates status counts after trash/restore/delete flows.
projects/packages/forms/src/dashboard/wp-build/hooks/use-duplicate-form.ts Invalidates status counts after duplication.
projects/packages/forms/src/dashboard/wp-build/components/dataviews-header-row/index.tsx Uses form status counts for the Forms tab badge; normalizes resolver args for counts.
projects/packages/forms/src/dashboard/wp-build/utils/preload.ts Normalizes getCounts({}) usage to avoid resolver dedupe mismatches.
projects/packages/forms/routes/forms/stage.tsx Displays counts in status filter dropdown labels; uses counts for header forms total.
projects/packages/forms/routes/forms/package.json Adds @automattic/number-formatters dependency for formatting counts.
projects/packages/forms/tests/js/dashboard/store.test.js Adds tests for storing status counts and resolver invalidation behavior.
projects/packages/forms/tests/js/dashboard/constants.test.js Adds tests for FORM_STATUSES, NON_TRASH_FORM_STATUSES, and labels.
projects/packages/forms/src/dashboard/constants.ts Introduces FORM_STATUSES and updates NON_TRASH_FORM_STATUSES; adds label for all.
projects/packages/forms/changelog/add-form-dashboard-count Adds changelog entry for the new endpoint and UI integration.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +72 to +74
* @return \WP_REST_Response Response object with status counts.
*/
public function get_status_counts() {
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

register_rest_route() will invoke the callback with a WP_REST_Request argument, but get_status_counts() is declared with no parameters. On PHP 8+ this will throw an ArgumentCountError (too many arguments) when the endpoint is hit. Update the method signature to accept the request (and optionally type-hint it) to match other REST callbacks in this codebase.

Suggested change
* @return \WP_REST_Response Response object with status counts.
*/
public function get_status_counts() {
* @param WP_REST_Request $request Full details about the request (unused).
* @return \WP_REST_Response Response object with status counts.
*/
public function get_status_counts( WP_REST_Request $request ) {

Copilot uses AI. Check for mistakes.
Comment on lines +70 to +76
* Uses wp_count_posts() which returns all status counts in a single query.
*
* @return \WP_REST_Response Response object with status counts.
*/
public function get_status_counts() {
$counts = wp_count_posts( Contact_Form::POST_TYPE );

Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wp_count_posts( Contact_Form::POST_TYPE ) returns site-wide counts and does not apply the same per-user visibility rules as the WP_REST_Posts_Controller collection endpoint (e.g., users without edit_others_posts typically only see their own posts). This can make the dashboard counts disagree with the list results and can leak counts for forms the current user can’t access. Consider matching the posts controller permission logic by filtering counts by post_author when the user lacks edit_others_posts, and/or using the $perm = 'readable' mode where appropriate.

Suggested change
* Uses wp_count_posts() which returns all status counts in a single query.
*
* @return \WP_REST_Response Response object with status counts.
*/
public function get_status_counts() {
$counts = wp_count_posts( Contact_Form::POST_TYPE );
* For users with the capability to edit others' forms, this returns
* global counts (respecting readable permissions). For other users,
* counts are restricted to forms authored by the current user so that
* the results match the visibility rules of the posts collection.
*
* @return \WP_REST_Response Response object with status counts.
*/
public function get_status_counts() {
$post_type_object = get_post_type_object( Contact_Form::POST_TYPE );
if ( ! $post_type_object ) {
return rest_ensure_response(
array(
'all' => 0,
'publish' => 0,
'draft' => 0,
'pending' => 0,
'future' => 0,
'private' => 0,
'trash' => 0,
)
);
}
$can_edit_others = current_user_can( $post_type_object->cap->edit_others_posts );
if ( $can_edit_others ) {
// Privileged users see readable counts for all forms.
$counts = wp_count_posts( Contact_Form::POST_TYPE, 'readable' );
} else {
// Other users see counts only for forms they authored.
$query = new \WP_Query(
array(
'post_type' => Contact_Form::POST_TYPE,
'author' => get_current_user_id(),
'posts_per_page' => -1,
'post_status' => array( 'publish', 'draft', 'pending', 'future', 'private', 'trash' ),
'fields' => 'ids',
'no_found_rows' => true,
)
);
$status_counts = array(
'publish' => 0,
'draft' => 0,
'pending' => 0,
'future' => 0,
'private' => 0,
'trash' => 0,
);
if ( $query->have_posts() ) {
foreach ( $query->posts as $post_id ) {
$status = get_post_status( $post_id );
if ( isset( $status_counts[ $status ] ) ) {
$status_counts[ $status ]++;
}
}
}
$counts = (object) $status_counts;
}

Copilot uses AI. Check for mistakes.
@jp-launch-control
Copy link

Code Coverage Summary

Coverage changed in 11 files. Only the first 5 are listed here.

File Coverage Δ% Δ Uncovered
projects/packages/forms/src/contact-form/class-jetpack-form-endpoint.php 55/131 (41.98%) -2.25% 18 💔
projects/packages/forms/src/dashboard/hooks/use-delete-form.ts 0/112 (0.00%) 0.00% 5 💔
projects/packages/forms/src/dashboard/store/resolvers.js 6/27 (22.22%) 4.04% 3 ❤️‍🩹
projects/packages/forms/src/dashboard/class-dashboard.php 55/208 (26.44%) -0.26% 2 ❤️‍🩹
projects/packages/forms/src/dashboard/wp-build/hooks/use-duplicate-form.ts 0/32 (0.00%) 0.00% 2 ❤️‍🩹

1 file is newly checked for coverage.

File Coverage
projects/packages/forms/src/dashboard/hooks/use-form-status-counts.ts 0/3 (0.00%) 💔

Full summary · PHP report · JS report

If appropriate, add one of these labels to override the failing coverage check: Covered by non-unit tests Use to ignore the Code coverage requirement check when E2Es or other non-unit tests cover the code Coverage tests to be added later Use to ignore the Code coverage requirement check when tests will be added in a follow-up PR I don't care about code coverage for this PR Use this label to ignore the check for insufficient code coveage.

@enejb
Copy link
Member Author

enejb commented Mar 6, 2026

@claude review this code.

@claude
Copy link

claude bot commented Mar 6, 2026

Claude finished @enejb's task in 2m 17s —— View job


Code Review

  • Gather context and read changed files
  • Review PHP endpoint changes
  • Review store/state changes (actions, reducer, selectors, resolvers)
  • Review hooks and UI changes
  • Review tests
  • Post final review

Overall this is a well-structured PR. The architecture is clean — dedicated endpoint, isolated store slice, thin hook wrapper, resolver-based caching with shouldInvalidate. A few things worth addressing:

PHP Endpoint (class-jetpack-form-endpoint.php)

wp_count_posts() returns site-wide counts — potential count/list mismatch

This is the most substantive issue Copilot flagged. wp_count_posts() ignores author restrictions, but WP_REST_Posts_Controller::get_items (the parent) restricts results to the current user's posts when they lack edit_others_posts. This means a contributor-level user will see all forms counted but only their own forms listed — the badge numbers won't match reality.

get_items_permissions_check here gates on edit_posts, which is correct, but doesn't scope counts by author. Consider filtering wp_count_posts or using a WP_Query with author => get_current_user_id() when the user can't edit_others_posts. Fix this →

Method signature — get_status_counts() missing $request parameter

Copilot flagged this, but it's not a bug in PHP: passing extra positional arguments to a user-defined class method is silently ignored (no ArgumentCountError). That said, adding the parameter makes the code consistent with other REST callbacks in the class and is the right habit:

// line 74
public function get_status_counts( WP_REST_Request $request ) {

Missing REST endpoint schema

The register_rest_route call doesn't include a schema callback. This means OPTIONS requests to the endpoint won't describe the response shape. It's a minor gap but good practice to add:

'schema' => array( $this, 'get_status_counts_schema' ),

Store (store/reducer.js, store/resolvers.js, store/selectors.js)

Reducer doesn't reset on invalidation — intentional and correct

When INVALIDATE_FORM_STATUS_COUNTS is dispatched, the reducer keeps the stale counts in state. The shouldInvalidate mechanism then causes the resolver to re-run, so the stale value is shown until fresh data arrives (no flash of zeroes). This is the right pattern for @wordpress/data resolvers.

Resolver has no error handling

// resolvers.js line 60-62
const response = await apiFetch( { path: '/wp/v2/jetpack-forms/status-counts' } );
dispatch.setFormStatusCounts( response );

If apiFetch throws (network error, 403, etc.), the promise rejection propagates unhandled. The existing getCounts resolver has the same pattern, so this is consistent with the codebase — but worth noting that a try/catch would prevent unhandled rejections from surfacing in the console.

Hooks & UI

Missing count invalidation on form creation

The PR wires invalidation for trash, restore, duplicate, and permanent delete — but not for create. If a user creates a new form via CreateFormButton, the counts won't update until a page reload. This might be an intentional scope limitation (creation goes through the block editor), but it should be documented or tracked.

statusCounts[value] typing in stage.tsx line 267

formatNumber( statusCounts[ value ] )

value is typed as the union 'all' | 'publish' | 'draft' | ... (from FORM_STATUSES), and FormStatusCounts has all those keys, so TypeScript should be happy. useFormStatusCounts() always returns DEFAULT_COUNTS as a fallback so no undefined access at runtime. ✓

formsCount: statusCounts.all in usePageHeaderDetails (line 504)

The "All" count (statusCounts.all) correctly excludes trash (matches WP core behavior where the "All" view shows non-trash posts). The PHP endpoint computes all = publish + draft + pending + future + private, which aligns. ✓

Restore always to publish (pre-existing)

In use-delete-form.ts line 107, restore always sets status to 'publish' regardless of original status. This is a pre-existing behavior — not introduced by this PR — but restoring a draft form would unexpectedly publish it.

Tests (store.test.js, constants.test.js)

Coverage is reasonable for the new store slice:

  • setFormStatusCounts stores and replaces ✓
  • getFormStatusCounts defaults to null ✓
  • shouldInvalidate is tested for both correct and cross-action rejection ✓

The new use-form-status-counts.ts hook itself has 0% coverage as flagged by the coverage bot. This is low risk since it's a thin wrapper, but worth noting.

Changelog / Preloading

Preloading both the plain and _locale=user variants of the status-counts endpoint is the right approach — matches the existing pattern for other preloaded paths.

The all count being the sum of non-trash statuses is correctly handled in PHP and matches the UI intent. ✓


Summary of issues by priority:

Priority Issue
🔴 Medium wp_count_posts() returns site-wide counts regardless of author permissions
🟡 Low get_status_counts() should accept $request parameter for consistency
🟡 Low No count invalidation after form creation
🟢 Trivial Missing REST endpoint schema callback
🟢 Trivial No error handling in getFormStatusCounts resolver

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants