diff --git a/projects/packages/forms/changelog/add-forms-unread-state-optimistic-updates2 b/projects/packages/forms/changelog/add-forms-unread-state-optimistic-updates2 new file mode 100644 index 0000000000000..113f19289110b --- /dev/null +++ b/projects/packages/forms/changelog/add-forms-unread-state-optimistic-updates2 @@ -0,0 +1,4 @@ +Significance: minor +Type: changed + +Forms: update read/unread counts optimistically in the sidebar diff --git a/projects/packages/forms/src/contact-form/class-contact-form-plugin.php b/projects/packages/forms/src/contact-form/class-contact-form-plugin.php index b7d5a82660900..2eab2b571acde 100644 --- a/projects/packages/forms/src/contact-form/class-contact-form-plugin.php +++ b/projects/packages/forms/src/contact-form/class-contact-form-plugin.php @@ -1415,9 +1415,7 @@ public function unread_count() { $unread = self::get_unread_count(); if ( isset( $submenu['jetpack'] ) && is_array( $submenu['jetpack'] ) && ! empty( $submenu['jetpack'] ) ) { - $inline_style = ( $unread > 0 ) ? '' : 'style="display: none;"'; - - $forms_unread_count_tag = " " . number_format_i18n( $unread ) . ''; + $forms_unread_count_tag = " " . number_format_i18n( $unread ) . ''; $jetpack_badge_count = $unread; // Main menu entries diff --git a/projects/packages/forms/src/dashboard/inbox/dataviews/actions.js b/projects/packages/forms/src/dashboard/inbox/dataviews/actions.js index 9cef329450c47..2baf911a1867e 100644 --- a/projects/packages/forms/src/dashboard/inbox/dataviews/actions.js +++ b/projects/packages/forms/src/dashboard/inbox/dataviews/actions.js @@ -7,7 +7,7 @@ import { store as noticesStore } from '@wordpress/notices'; import { notSpam, spam } from '../../icons'; import { store as dashboardStore } from '../../store'; import InboxResponse from '../response'; -import { updateMenuCounter } from '../utils'; +import { updateMenuCounter, updateMenuCounterOptimistically } from '../utils'; export const BULK_ACTIONS = { markAsSpam: 'mark_as_spam', @@ -340,8 +340,9 @@ export const markAsReadAction = { const { editEntityRecord } = registry.dispatch( coreStore ); const { getEntityRecord } = registry.select( coreStore ); const { createSuccessNotice, createErrorNotice } = registry.dispatch( noticesStore ); + const promises = await Promise.allSettled( - items.map( async ( { id } ) => { + items.map( async ( { id, status } ) => { // Get current entity from store const currentEntity = getEntityRecord( 'postType', 'feedback', id ); @@ -350,6 +351,11 @@ export const markAsReadAction = { editEntityRecord( 'postType', 'feedback', id, { is_unread: false, } ); + + // Immediately update menu counters optimistically to avoid delays, but only for inbox + if ( status === 'publish' ) { + updateMenuCounterOptimistically( -1 ); + } } // Update on server @@ -359,7 +365,7 @@ export const markAsReadAction = { data: { is_unread: false }, } ) .then( ( { count } ) => { - // Update the unread count in the menu. + // Update menu counter with accurate count from server. updateMenuCounter( count ); } ) .catch( () => { @@ -368,6 +374,11 @@ export const markAsReadAction = { editEntityRecord( 'postType', 'feedback', id, { is_unread: true, } ); + + // Revert the optimistic change in the sidebar. + if ( status === 'publish' ) { + updateMenuCounterOptimistically( 1 ); + } } throw new Error( 'Failed to mark as read' ); } ); @@ -419,7 +430,7 @@ export const markAsUnreadAction = { const { getEntityRecord } = registry.select( coreStore ); const { createSuccessNotice, createErrorNotice } = registry.dispatch( noticesStore ); const promises = await Promise.allSettled( - items.map( async ( { id } ) => { + items.map( async ( { id, status } ) => { // Get current entity from store const currentEntity = getEntityRecord( 'postType', 'feedback', id ); @@ -428,6 +439,11 @@ export const markAsUnreadAction = { editEntityRecord( 'postType', 'feedback', id, { is_unread: true, } ); + + // Immediately update menu counters optimistically to avoid delays, but only for inbox + if ( status === 'publish' ) { + updateMenuCounterOptimistically( 1 ); + } } // Update on server @@ -437,7 +453,7 @@ export const markAsUnreadAction = { data: { is_unread: true }, } ) .then( ( { count } ) => { - // Update the unread count in the menu. + // Update menu counter with accurate count from server. updateMenuCounter( count ); } ) .catch( () => { @@ -446,6 +462,11 @@ export const markAsUnreadAction = { editEntityRecord( 'postType', 'feedback', id, { is_unread: false, } ); + + // Revert the optimistic change in the sidebar. + if ( status === 'publish' ) { + updateMenuCounterOptimistically( -1 ); + } } throw new Error( 'Failed to mark as unread' ); } ); diff --git a/projects/packages/forms/src/dashboard/inbox/response.js b/projects/packages/forms/src/dashboard/inbox/response.js index 06dcd61965c0b..964e90232f627 100644 --- a/projects/packages/forms/src/dashboard/inbox/response.js +++ b/projects/packages/forms/src/dashboard/inbox/response.js @@ -37,7 +37,7 @@ import { markAsReadAction, markAsUnreadAction, } from './dataviews/actions'; -import { getPath, updateMenuCounter } from './utils'; +import { getPath, updateMenuCounter, updateMenuCounterOptimistically } from './utils'; const getDisplayName = response => { const { author_name, author_email, author_url, ip } = response; @@ -550,6 +550,11 @@ const InboxResponse = ( { is_unread: false, } ); + // Immediately update menu counters optimistically to avoid delays + if ( response.status === 'publish' ) { + updateMenuCounterOptimistically( -1 ); + } + // Then update on server apiFetch( { path: `/wp/v2/feedback/${ response.id }/read`, @@ -557,12 +562,19 @@ const InboxResponse = ( { data: { is_unread: false }, } ) .then( ( { count } ) => { + // Update menu counter with accurate count from server updateMenuCounter( count ); } ) .catch( () => { + // Revert the change in the store editEntityRecord( 'postType', 'feedback', response.id, { is_unread: true, } ); + + // Revert the change in the sidebar + if ( response.status === 'publish' ) { + updateMenuCounterOptimistically( 1 ); + } } ); }, [ response, editEntityRecord, hasMarkedSelfAsRead ] ); diff --git a/projects/packages/forms/src/dashboard/inbox/utils.js b/projects/packages/forms/src/dashboard/inbox/utils.js index add9f7c8a2e03..1dd7ebd1094c1 100644 --- a/projects/packages/forms/src/dashboard/inbox/utils.js +++ b/projects/packages/forms/src/dashboard/inbox/utils.js @@ -1,3 +1,5 @@ +import { formatNumber } from '@automattic/number-formatters'; + // Function to get the URL of the page or post where the form was submitted. export const getPath = item => { try { @@ -9,20 +11,57 @@ export const getPath = item => { }; /** - * Update the unread count in the admin menu. + * Update `count-0` style CSS class in the unread menu badge with new count like `count-1`. + * + * @param {HTMLElement} element - Counter badge element + * @param {number} count - Count to use in new CSS class + */ +function updateBadge( element, count ) { + const oldClass = [ ...element.classList ].find( c => c.startsWith( 'count-' ) ); + if ( oldClass ) { + element.classList.replace( oldClass, `count-${ count }` ); + } else { + element.classList.add( `count-${ count }` ); + } + + element.ariaHidden = count > 0 ? 'false' : 'true'; + element.textContent = formatNumber( count ); +} + +/** + * Update the unread count in the admin menu to specific count. * * @param {number} count - The new unread count. */ export const updateMenuCounter = count => { - // iterate over all elements with the class 'jp-feedback-unread-counter' and update their text content + // Iterate over all badges with the class 'jp-feedback-unread-counter' and update their count document.querySelectorAll( '.jp-feedback-unread-counter' ).forEach( item => { + // Jetpack menu item has combined count and forms unread counter if ( item.dataset.unreadDiff ) { - const newCount = parseInt( item.dataset.unreadDiff, 10 ) + count; - item.textContent = newCount > 0 ? newCount : ''; - item.style.display = newCount > 0 ? '' : 'none'; + const unreadDiff = parseInt( item.dataset.unreadDiff, 10 ) + count; + updateBadge( item, unreadDiff ); } else { - item.textContent = count > 0 ? count : ''; - item.style.display = count > 0 ? '' : 'none'; + updateBadge( item, count ); + } + } ); +}; + +/** + * Update the unread count in the admin menu by addition or substraction, not by knowing the actual count. + * + * @param {number} count - By how much we should add or substract from the current sidebar menu count; either positive or negative integer. + */ +export const updateMenuCounterOptimistically = count => { + // Iterate over all badges with the class 'jp-feedback-unread-counter' and update their count + document.querySelectorAll( '.jp-feedback-unread-counter' ).forEach( item => { + let optimisticCount = 0; + if ( item.textContent !== '' ) { + // Ensure large formatted numbers like "1,000" are converted to integers properly + optimisticCount = parseInt( item.textContent.replace( /\D/g, '' ), 10 ) + count; + } + + if ( optimisticCount >= 0 ) { + updateBadge( item, optimisticCount ); } } ); };