Skip to content

Commit 5cd1b3b

Browse files
enejbCopilot
andauthored
Forms: Refactor feedback status tracking and add unread count refresh (#46505)
* Forms: Refactor feedback status tracking and add tests Replaces track_spam_status_change with track_feedback_status_change to handle feedback post status transitions, including spam and publish. Adds private helpers for spam meta and unread count recalculation. Comprehensive unit tests added for feedback status changes, spam meta updates, unread count recalculation, and non-feedback post handling. * Add change log * Fix the frontend as well. * Update projects/packages/forms/src/contact-form/class-contact-form-plugin.php Co-authored-by: Copilot <[email protected]> * Fixing typos Co-authored-by: Copilot <[email protected]> * Add tests for updateMenuCounter and updateMenuCounterOptimistically * fix typos * Add js tests * Update actions.tsx * Refactor processStatusChange to separate module Moved the processStatusChange logic from actions.tsx to a new process-status-change.ts file for better modularity and maintainability. Updated imports accordingly and enhanced tests to cover error handling and revert logic for menu counter updates. * use shutdown hook instead * Fix typo Co-authored-by: Copilot <[email protected]> * Move withTimeout to utils * minor fixes * fix changelog * minor remove comment * make the tests pass again * update changelog * minor fixes to comments * Improve the class calling for readability * Improve tests. Check that the action in registered * Making undo work --------- Co-authored-by: Copilot <[email protected]>
1 parent a7c8882 commit 5cd1b3b

File tree

9 files changed

+735
-122
lines changed

9 files changed

+735
-122
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Significance: patch
2+
Type: changed
3+
4+
Fix unread count not updating when feedback status changes between published and unpublished states.

projects/packages/forms/src/contact-form/class-contact-form-plugin.php

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -320,8 +320,8 @@ protected function __construct() {
320320
)
321321
);
322322

323-
// Track when post status changes to 'spam' for accurate deletion timing
324-
add_action( 'transition_post_status', array( $this, 'track_spam_status_change' ), 10, 3 );
323+
// Track when post status changes to feedback posts types.
324+
add_action( 'transition_post_status', array( $this, 'track_feedback_status_change' ), 10, 3 );
325325

326326
// POST handler
327327
if (
@@ -3610,8 +3610,12 @@ public function untrash_feedback_status_handler( $current_status, $post_id, $pre
36103610
* @param string $new_status The new post status.
36113611
* @param string $old_status The old post status.
36123612
* @param WP_Post|null $post The post object, when available.
3613+
*
3614+
* @deprecated since $$next-version$$
36133615
*/
36143616
public function track_spam_status_change( $new_status, $old_status, ?WP_Post $post = null ) {
3617+
_deprecated_function( __METHOD__, 'package-jetpack-forms-$$next-version$$' );
3618+
36153619
if ( ! $post instanceof WP_Post ) {
36163620
// Some callers fire the action without a populated post object (e.g. failed get_post lookups).
36173621
return;
@@ -3622,13 +3626,67 @@ public function track_spam_status_change( $new_status, $old_status, ?WP_Post $po
36223626
return;
36233627
}
36243628

3629+
$this->track_spam_status( $new_status, $old_status, $post->ID );
3630+
}
3631+
3632+
/**
3633+
* Tracks when a feedback post status changes and triggers related handlers.
3634+
* Used to handle spam meta tracking and unread count recalculation for feedback posts.
3635+
*
3636+
* @param string $new_status The new post status.
3637+
* @param string $old_status The old post status.
3638+
* @param WP_Post|null $post The post object, when available.
3639+
*/
3640+
public function track_feedback_status_change( $new_status, $old_status, ?WP_Post $post = null ) {
3641+
if ( ! $post instanceof WP_Post ) {
3642+
// Some callers fire the action without a populated post object (e.g. failed get_post lookups).
3643+
return;
3644+
}
3645+
3646+
// Only track for feedback posts
3647+
if ( 'feedback' !== $post->post_type ) {
3648+
return;
3649+
}
3650+
$this->track_spam_status( $new_status, $old_status, $post->ID );
3651+
$this->track_recount_unread( $new_status, $old_status, $post );
3652+
}
3653+
3654+
/**
3655+
* Tracks when a feedback post status changes to 'spam' and stores the timestamp.
3656+
* This allows us to accurately determine when spam was marked, independent of other post updates.
3657+
*
3658+
* @param string $new_status The new post status.
3659+
* @param string $old_status The old post status.
3660+
* @param int $post_id The post ID.
3661+
*/
3662+
private function track_spam_status( $new_status, $old_status, $post_id ) {
36253663
// Only track when status changes TO spam (not from spam to something else)
36263664
if ( 'spam' === $new_status && 'spam' !== $old_status ) {
36273665
// Store the current GMT timestamp when status changes to spam
3628-
update_post_meta( $post->ID, '_spam_status_changed_gmt', current_time( 'mysql', 1 ) );
3666+
update_post_meta( $post_id, '_spam_status_changed_gmt', current_time( 'mysql', 1 ) );
36293667
} elseif ( 'spam' === $old_status && 'spam' !== $new_status ) {
36303668
// Remove the meta when post is no longer spam
3631-
delete_post_meta( $post->ID, '_spam_status_changed_gmt' );
3669+
delete_post_meta( $post_id, '_spam_status_changed_gmt' );
3670+
}
3671+
}
3672+
3673+
/**
3674+
* Tracks when a feedback post status changes to or from 'publish' and triggers unread count recalculation.
3675+
*
3676+
* @param string $new_status The new post status.
3677+
* @param string $old_status The old post status.
3678+
* @param WP_Post $post The post object.
3679+
*/
3680+
private function track_recount_unread( $new_status, $old_status, WP_Post $post ) {
3681+
// If the feedback is already marked as read, it doesn't matter if its status changes.
3682+
if ( $post->comment_status === Feedback::STATUS_READ ) {
3683+
return;
3684+
}
3685+
3686+
// If the status changed to or from 'publish', we need to recount unread feedbacks.
3687+
if ( ( 'publish' === $new_status && 'publish' !== $old_status ) ||
3688+
( 'publish' === $old_status && 'publish' !== $new_status ) ) {
3689+
add_action( 'shutdown', array( __CLASS__, 'recalculate_unread_count' ) );
36323690
}
36333691
}
36343692

projects/packages/forms/src/dashboard/inbox/stage/actions.tsx

Lines changed: 18 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,13 @@ import { store as noticesStore } from '@wordpress/notices';
1313
*/
1414
import { notSpam, spam } from '../../icons/index.ts';
1515
import { store as dashboardStore } from '../../store/index.js';
16-
import { updateMenuCounter, updateMenuCounterOptimistically } from '../utils.js';
16+
import { updateMenuCounter, updateMenuCounterOptimistically, withTimeout } from '../utils.js';
17+
import { optimisticallyUpdateUnreadCount, processStatusChange } from './process-status-change';
1718
import { defaultView } from './views.js';
1819
/**
1920
* Types
2021
*/
21-
import type { Action, DispatchActions, QueryParams, Registry } from './types.tsx';
22-
import type { FormResponse } from '../../../types/index.ts';
22+
import type { Action, QueryParams, Registry } from './types.tsx';
2323

2424
/**
2525
* Helper function to extract count-relevant query params from the current query.
@@ -132,26 +132,6 @@ const getGenericErrorMessage = ( numberOfErrors: number ): string => {
132132
);
133133
};
134134

135-
/**
136-
* Wraps a promise with a timeout to ensure it rejects after a reasonable time.
137-
* This is useful for network requests that might hang when the network is disabled.
138-
*
139-
* @param {Promise} promise - The promise to wrap.
140-
* @param {number} timeoutMs - The timeout in milliseconds (default: 30000).
141-
* @return {Promise} The wrapped promise that will reject on timeout.
142-
*/
143-
const withTimeout = (
144-
promise: Promise< unknown >,
145-
timeoutMs: number = 30000
146-
): Promise< unknown > => {
147-
return Promise.race( [
148-
promise,
149-
new Promise( ( _, reject ) =>
150-
setTimeout( () => reject( new Error( 'Request timeout' ) ), timeoutMs )
151-
),
152-
] );
153-
};
154-
155135
/*
156136
* Waits until the current entity records query resolves (or times out).
157137
*/
@@ -176,101 +156,6 @@ const waitForEntityRecordsResolution = async (
176156
}
177157
};
178158

179-
/**
180-
* Type for the result of processStatusChange.
181-
*/
182-
type StatusChangeResult = {
183-
itemsUpdated: { id: number }[];
184-
itemsFailed: number[];
185-
numberOfErrors: number;
186-
};
187-
188-
type ProcessStatusChangeParams = {
189-
items: FormResponse[];
190-
newStatus: string;
191-
apiCall: ( id: number ) => Promise< unknown >;
192-
editEntityRecord: DispatchActions[ 'editEntityRecord' ];
193-
updateCountsOptimistically: DispatchActions[ 'updateCountsOptimistically' ];
194-
queryParams: QueryParams;
195-
};
196-
197-
/**
198-
* Helper function to process status changes with optimistic updates and error handling.
199-
* Optimistic Update Strategy:
200-
* 1. Immediately update local state and counts
201-
* 2. Make API call
202-
* 3. On success: invalidate cache to sync with server
203-
* 4. On failure: rollback local changes
204-
* 5. Undo actions must preserve original status for proper restoration
205-
* @param {object} params - The parameters for the status change.
206-
* @param {FormResponse[]} params.items - The items to update.
207-
* @param {string} params.newStatus - The new status to set.
208-
* @param {Function} params.apiCall - The API call function (saveEntityRecord or deleteEntityRecord).
209-
* @param {Function} params.editEntityRecord - The editEntityRecord dispatch function.
210-
* @param {Function} params.updateCountsOptimistically - The updateCountsOptimistically dispatch function.
211-
* @param {QueryParams} params.queryParams - The query params for count updates.
212-
* @return {Promise<StatusChangeResult>} The result of the status change operation.
213-
*/
214-
const processStatusChange = async ( {
215-
items,
216-
newStatus,
217-
apiCall,
218-
editEntityRecord,
219-
updateCountsOptimistically,
220-
queryParams,
221-
}: ProcessStatusChangeParams ): Promise< StatusChangeResult > => {
222-
// Store original statuses before making optimistic changes
223-
const originalStatuses = items.map( item => item.status );
224-
225-
// Make optimistic updates
226-
items.forEach( item => {
227-
editEntityRecord( 'postType', 'feedback', item.id, {
228-
status: newStatus,
229-
} );
230-
231-
// Update counts optimistically
232-
updateCountsOptimistically( item.status, newStatus, 1, queryParams );
233-
} );
234-
235-
// Call API with timeout
236-
const promises = await Promise.allSettled(
237-
items.map( ( { id } ) => withTimeout( apiCall( id ) ) as Promise< { id: number } > )
238-
);
239-
240-
// Check for both rejected promises and fulfilled promises with undefined/invalid results
241-
// const itemsUpdated: PromiseFulfilledResult< { id: number } >[] = [];
242-
const itemsUpdated: { id: number }[] = [];
243-
const itemsFailed: number[] = [];
244-
245-
promises.forEach( ( promise, index ) => {
246-
// Failed if rejected OR if fulfilled but result is invalid
247-
if ( promise.status === 'rejected' || ! promise.value?.id ) {
248-
itemsFailed.push( index );
249-
} else {
250-
itemsUpdated.push( promise.value );
251-
}
252-
} );
253-
254-
// Revert optimistic changes for failed items
255-
itemsFailed.forEach( index => {
256-
const item = items[ index ];
257-
const originalStatus = originalStatuses[ index ];
258-
259-
editEntityRecord( 'postType', 'feedback', item.id, {
260-
status: originalStatus,
261-
} );
262-
263-
// Revert the count change
264-
updateCountsOptimistically( newStatus, originalStatus, 1, queryParams );
265-
} );
266-
267-
return {
268-
itemsUpdated,
269-
itemsFailed,
270-
numberOfErrors: itemsFailed.length,
271-
};
272-
};
273-
274159
export const BULK_ACTIONS = {
275160
markAsSpam: 'mark_as_spam',
276161
markAsNotSpam: 'mark_as_not_spam',
@@ -417,6 +302,9 @@ export const markAsSpamAction: Action = {
417302
} else {
418303
// Remove the info notice when undo completes successfully
419304
removeNotice( 'mark-as-spam-action' );
305+
items.forEach( item => {
306+
optimisticallyUpdateUnreadCount( 'spam', 'publish', item.is_unread );
307+
} );
420308
}
421309
} else {
422310
// There is at least one failure.
@@ -551,6 +439,9 @@ export const markAsNotSpamAction: Action = {
551439
} );
552440
} else {
553441
removeNotice( 'mark-as-not-spam-action' );
442+
items.forEach( item => {
443+
optimisticallyUpdateUnreadCount( 'publish', 'spam', item.is_unread );
444+
} );
554445
}
555446
} else {
556447
// There is at least one failure.
@@ -678,6 +569,10 @@ export const restoreAction: Action = {
678569
} );
679570
} else {
680571
removeNotice( 'restore-action' );
572+
items.forEach( item => {
573+
// Since you can send an item to Trash from both Spam and Inbox, we need to restore the unread count based on the new status.
574+
optimisticallyUpdateUnreadCount( newStatus, 'trash', item.is_unread );
575+
} );
681576
}
682577

683578
return;
@@ -818,6 +713,11 @@ export const moveToTrashAction: Action = {
818713
} );
819714
} else {
820715
removeNotice( 'move-to-trash-action' );
716+
// This is an undo action that moves the item from and is triggered by Undo in restore.
717+
// Which means that we are on the Trash view and wanted to restore the item back to 'publish' but then decided to undo that.
718+
items.forEach( item => {
719+
optimisticallyUpdateUnreadCount( 'trash', 'publish', item.is_unread );
720+
} );
821721
}
822722

823723
return;

0 commit comments

Comments
 (0)