diff --git a/projects/packages/forms/changelog/change-forms-use-response-navigation b/projects/packages/forms/changelog/change-forms-use-response-navigation
new file mode 100644
index 0000000000000..f4469b95bbc54
--- /dev/null
+++ b/projects/packages/forms/changelog/change-forms-use-response-navigation
@@ -0,0 +1,4 @@
+Significance: patch
+Type: changed
+
+Forms: remove old file, add tests for hook
diff --git a/projects/packages/forms/src/dashboard/inbox/response.js b/projects/packages/forms/src/dashboard/components/response-view/body.tsx
similarity index 90%
rename from projects/packages/forms/src/dashboard/inbox/response.js
rename to projects/packages/forms/src/dashboard/components/response-view/body.tsx
index 8521218dc5107..7b33e8a4778ef 100644
--- a/projects/packages/forms/src/dashboard/inbox/response.js
+++ b/projects/packages/forms/src/dashboard/components/response-view/body.tsx
@@ -24,13 +24,12 @@ import clsx from 'clsx';
/**
* Internal dependencies
*/
-import useFormsConfig from '../../hooks/use-forms-config';
-import CopyClipboardButton from '../components/copy-clipboard-button';
-import Gravatar from '../components/gravatar';
-import ResponseActions from '../components/response-actions';
-import ResponseNavigation from '../components/response-navigation';
-import { useMarkAsSpam } from '../hooks/use-mark-as-spam';
-import { getPath, updateMenuCounter, updateMenuCounterOptimistically } from './utils';
+import useFormsConfig from '../../../hooks/use-forms-config';
+import { useMarkAsSpam } from '../../hooks/use-mark-as-spam';
+import { getPath, updateMenuCounter, updateMenuCounterOptimistically } from '../../inbox/utils';
+import CopyClipboardButton from '../copy-clipboard-button';
+import Gravatar from '../gravatar';
+import type { FormResponse } from '../../../types';
const getDisplayName = response => {
const { author_name, author_email, author_url, ip } = response;
@@ -176,31 +175,41 @@ const FileField = ( { file, onClick } ) => {
);
};
-const InboxResponse = ( {
+export type ResponseViewBodyProps = {
+ response: FormResponse;
+ isLoading: boolean;
+ onModalStateChange?: ( toggleOpen: boolean ) => void;
+ isMobile?: boolean;
+};
+
+/**
+ * Renders the dashboard response view.
+ *
+ * @param {object} props - The props object.
+ * @param {object} props.response - The response item.
+ * @param {boolean} props.isLoading - Whether the response is loading.
+ * @param {Function} props.onModalStateChange - Function to update the modal state.
+ * @return {import('react').JSX.Element} The dashboard response view.
+ */
+const ResponseViewBody = ( {
response,
- loading,
+ isLoading,
onModalStateChange,
- onClose,
- onNext,
- onPrevious,
- hasNext,
- hasPrevious,
- onActionComplete,
- isMobile,
-} ) => {
+}: ResponseViewBodyProps ): import('react').JSX.Element => {
const [ isPreviewModalOpen, setIsPreviewModalOpen ] = useState( false );
- const [ previewFile, setPreviewFile ] = useState( null );
+ const [ previewFile, setPreviewFile ] = useState< null | object >( null );
const [ isImageLoading, setIsImageLoading ] = useState( true );
- const [ hasMarkedSelfAsRead, setHasMarkedSelfAsRead ] = useState( false );
+ const [ hasMarkedSelfAsRead, setHasMarkedSelfAsRead ] = useState( 0 );
const { editEntityRecord } = useDispatch( 'core' );
const formsConfig = useFormsConfig();
const emptyTrashDays = formsConfig?.emptyTrashDays ?? 0;
- // When opening a "Mark as spam" link from the email, the InboxResponse component is rendered, so we use a hook here to handle it.
- const { isConfirmDialogOpen, onConfirmMarkAsSpam, onCancelMarkAsSpam } =
- useMarkAsSpam( response );
+ // When opening a "Mark as spam" link from the email, the ResponseViewBody component is rendered, so we use a hook here to handle it.
+ const { isConfirmDialogOpen, onConfirmMarkAsSpam, onCancelMarkAsSpam } = useMarkAsSpam(
+ response as FormResponse
+ );
const ref = useRef( undefined );
@@ -372,7 +381,7 @@ const InboxResponse = ( {
return setIsImageLoading( false );
}, [ setIsImageLoading ] );
- if ( ! loading && ! response ) {
+ if ( ! isLoading && ! response ) {
return null;
}
@@ -390,22 +399,6 @@ const InboxResponse = ( {
return (
<>
- { ! isMobile && (
-
-
-
-
-
-
-
-
- ) }
@@ -475,7 +468,7 @@ const InboxResponse = ( {
{ isPreviewModalOpen && previewFile && onModalStateChange && (
@@ -516,4 +509,4 @@ const InboxResponse = ( {
);
};
-export default InboxResponse;
+export default ResponseViewBody;
diff --git a/projects/packages/forms/src/dashboard/components/response-view/index.tsx b/projects/packages/forms/src/dashboard/components/response-view/index.tsx
index 41a04d07769f0..838bbb19c0c41 100644
--- a/projects/packages/forms/src/dashboard/components/response-view/index.tsx
+++ b/projects/packages/forms/src/dashboard/components/response-view/index.tsx
@@ -1,514 +1,7 @@
-/**
- * External dependencies
- */
-import apiFetch from '@wordpress/api-fetch';
-import {
- Button,
- ExternalLink,
- Modal,
- Tooltip,
- Spinner,
- Icon,
- Tip,
- __experimentalConfirmDialog as ConfirmDialog, // eslint-disable-line @wordpress/no-unsafe-wp-apis
- __experimentalHStack as HStack, // eslint-disable-line @wordpress/no-unsafe-wp-apis
- __experimentalVStack as VStack, // eslint-disable-line @wordpress/no-unsafe-wp-apis
-} from '@wordpress/components';
-import { useDispatch } from '@wordpress/data';
-import { dateI18n, getSettings as getDateSettings } from '@wordpress/date';
-import { useCallback, useEffect, useRef, useState } from '@wordpress/element';
-import { decodeEntities } from '@wordpress/html-entities';
-import { __, _n, sprintf } from '@wordpress/i18n';
-import { download } from '@wordpress/icons';
-import clsx from 'clsx';
-/**
- * Internal dependencies
- */
-import useFormsConfig from '../../../hooks/use-forms-config';
-import { useMarkAsSpam } from '../../hooks/use-mark-as-spam';
-import { getPath, updateMenuCounter, updateMenuCounterOptimistically } from '../../inbox/utils';
-import CopyClipboardButton from '../copy-clipboard-button';
-import Gravatar from '../gravatar';
+import ResponseViewBody from './body.tsx';
import ResponseMobileView from './mobile.tsx';
-import type { FormResponse } from '../../../types';
+import SingleResponseView from './single.tsx';
-const getDisplayName = response => {
- const { author_name, author_email, author_url, ip } = response;
- return decodeEntities( author_name || author_email || author_url || ip );
-};
-
-const isFileUploadField = value => {
- return value && typeof value === 'object' && 'files' in value;
-};
-
-const isImageSelectField = value => {
- return value?.type === 'image-select';
-};
-
-const isLikelyPhoneNumber = value => {
- // Only operate on strings to avoid coercing numbers (e.g., 2024) into strings that could match
- if ( typeof value !== 'string' ) {
- return false;
- }
-
- const normalizedValue = value.trim();
-
- // Allow only digits, spaces, parentheses, hyphens, dots, plus
- if ( ! /^[\d+\-\s().]+$/.test( normalizedValue ) ) {
- return false;
- }
-
- // Exclude common date formats to avoid false positives
- // - ISO-like: 2025-11-01 or 2025/11/01
- if ( /^\d{4}[-/]\d{1,2}[-/]\d{1,2}$/.test( normalizedValue ) ) {
- return false;
- }
- // - Locale-like: 01/11/2025, 1/11/25, 11-01-2025
- if ( /^\d{1,2}[-/]\d{1,2}[-/]\d{2,4}$/.test( normalizedValue ) ) {
- return false;
- }
-
- // Strip non-digits and validate digit count within a typical global range
- const digits = normalizedValue.replace( /\D/g, '' );
- if ( digits.length < 7 || digits.length > 15 ) {
- return false;
- }
-
- return true;
-};
-
-const PreviewFile = ( { file, isLoading, onImageLoaded } ) => {
- const imageClass = clsx( 'jp-forms__inbox-file-preview-container', {
- 'is-loading': isLoading,
- } );
-
- return (
-
- { isLoading && (
-
-
-
- { __( 'Loading preview…', 'jetpack-forms' ) }
-
-
- ) }
-
-
-

-
-
- );
-};
-
-const FileField = ( { file, onClick } ) => {
- const fileExtension = file.name.split( '.' ).pop().toLowerCase();
- const fileType = file.type.split( '/' )[ 0 ];
-
- const iconMap = {
- image: 'png',
- video: 'mp4',
- audio: 'mp3',
- document: 'pdf',
- application: 'txt',
- };
-
- const extensionMap = {
- pdf: 'pdf',
- png: 'png',
- jpg: 'png',
- jpeg: 'png',
- gif: 'png',
- mp4: 'mp4',
- mp3: 'mp3',
- webm: 'webm',
- doc: 'doc',
- docx: 'doc',
- txt: 'txt',
- ppt: 'ppt',
- pptx: 'ppt',
- xls: 'xls',
- xlsx: 'xls',
- csv: 'xls',
- zip: 'zip',
- sql: 'sql',
- cal: 'cal',
- };
- const iconType = extensionMap[ fileExtension ] || iconMap[ fileType ] || 'txt';
- const iconClass = clsx( 'file-field__icon', 'icon-' + iconType );
- return (
-
-
-
-
- { file.is_previewable && (
-
- ) }
- { ! file.is_previewable && (
-
- { decodeEntities( file.name ) }
-
- ) }
-
- { sprintf(
- /* translators: %1$s size of the file and %2$s is the file extension */
- __( '%1$s, %2$s', 'jetpack-forms' ),
- file.size,
- fileExtension.toUpperCase()
- ) }
-
-
-
-
-
-
-
-
-
- );
-};
-
-export type ResponseViewProps = {
- response: FormResponse;
- isLoading: boolean;
- onModalStateChange?: ( toggleOpen: boolean ) => void;
- isMobile?: boolean;
-};
-
-/**
- * Renders the dashboard response view.
- *
- * @param {object} props - The props object.
- * @param {object} props.response - The response item.
- * @param {boolean} props.isLoading - Whether the response is loading.
- * @param {Function} props.onModalStateChange - Function to update the modal state.
- * @return {import('react').JSX.Element} The dashboard response view.
- */
-const ResponseView = ( {
- response,
- isLoading,
- onModalStateChange,
-}: ResponseViewProps ): import('react').JSX.Element => {
- const [ isPreviewModalOpen, setIsPreviewModalOpen ] = useState( false );
- const [ previewFile, setPreviewFile ] = useState< null | object >( null );
- const [ isImageLoading, setIsImageLoading ] = useState( true );
- const [ hasMarkedSelfAsRead, setHasMarkedSelfAsRead ] = useState( 0 );
-
- const { editEntityRecord } = useDispatch( 'core' );
-
- const formsConfig = useFormsConfig();
- const emptyTrashDays = formsConfig?.emptyTrashDays ?? 0;
-
- // When opening a "Mark as spam" link from the email, the ResponseView component is rendered, so we use a hook here to handle it.
- const { isConfirmDialogOpen, onConfirmMarkAsSpam, onCancelMarkAsSpam } = useMarkAsSpam(
- response as FormResponse
- );
-
- const ref = useRef( undefined );
-
- const openFilePreview = useCallback(
- file => {
- setIsImageLoading( true );
- setPreviewFile( file );
- setIsPreviewModalOpen( true );
- if ( onModalStateChange ) {
- onModalStateChange( true );
- }
- },
- [ onModalStateChange, setPreviewFile, setIsPreviewModalOpen ]
- );
-
- const handleFilePreview = useCallback(
- file => openFilePreview.bind( null, file ),
- [ openFilePreview ]
- );
-
- const closePreviewModal = useCallback( () => {
- setIsPreviewModalOpen( false );
- setIsImageLoading( true );
- // Notify parent component that this modal is closed
- if ( onModalStateChange ) {
- onModalStateChange( false );
- }
- }, [ onModalStateChange, setIsPreviewModalOpen, setIsImageLoading ] );
-
- const renderFieldValue = value => {
- if ( isImageSelectField( value ) ) {
- return (
-
- { ( value.choices?.length ?? 0 ) === 0 && '-' }
- { ( value.choices?.length ?? 0 ) > 0 && (
- <>
-
- { value.choices
- .map( choice => {
- let transformedValue = choice.selected;
-
- if ( choice.label != null && choice.label !== '' ) {
- transformedValue += ' - ' + choice.label;
- }
-
- return transformedValue;
- } )
- .join( ', ' ) }
-
-
- { value.choices.map( choice => {
- return (
-

- );
- } ) }
-
- >
- ) }
-
- );
- }
-
- if ( isFileUploadField( value ) ) {
- return (
-
- { value.files?.length
- ? value.files.map( file => {
- if ( ! file || ! file.name ) {
- return '-';
- }
- return (
-
- );
- } )
- : '-' }
-
- );
- }
-
- // Emails
- const emailRegEx = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i;
- if ( emailRegEx.test( value ) ) {
- return (
-
- );
- }
-
- // Phone numbers
- if ( isLikelyPhoneNumber( value ) ) {
- return (
-
- );
- }
-
- return value;
- };
-
- useEffect( () => {
- if ( ! ref.current ) {
- return;
- }
-
- ref.current.scrollTop = 0;
- }, [ response ] );
-
- // Mark feedback as read when viewing
- useEffect( () => {
- if ( ! response || ! response.id || ! response.is_unread ) {
- setHasMarkedSelfAsRead( response.id );
- return;
- }
- if ( hasMarkedSelfAsRead === response.id ) {
- return;
- }
-
- setHasMarkedSelfAsRead( response.id );
-
- // Immediately update entity in store
- editEntityRecord( 'postType', 'feedback', response.id, {
- 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`,
- method: 'POST',
- 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 ] );
-
- const handelImageLoaded = useCallback( () => {
- return setIsImageLoading( false );
- }, [ setIsImageLoading ] );
-
- if ( ! isLoading && ! response ) {
- return null;
- }
-
- if ( isPreviewModalOpen && ! onModalStateChange ) {
- return (
-
- );
- }
-
- const displayName = getDisplayName( response );
-
- return (
- <>
-
-
-
- { response.author_email && (
-
- ) }
-
- { displayName }
- { response.author_email && displayName !== response.author_email && (
-
- { response.author_email }
-
-
- ) }
-
-
-
-
-
-
-
- { __( 'Date:', 'jetpack-forms' ) }
-
-
- { sprintf(
- /* Translators: %1$s is the date, %2$s is the time. */
- __( '%1$s at %2$s', 'jetpack-forms' ),
- dateI18n( getDateSettings().formats.date, response.date ),
- dateI18n( getDateSettings().formats.time, response.date )
- ) }
-
-
-
-
- { __( 'Source:', 'jetpack-forms' ) }
-
-
-
- { decodeEntities( response.entry_title ) || getPath( response ) }
-
-
-
-
-
- { __( 'IP address:', 'jetpack-forms' ) }
-
- { response.ip }
-
-
-
-
- { Object.entries( response.fields ).map( ( [ key, value ] ) => (
-
-
- { key.endsWith( '?' ) ? key : `${ key }:` }
-
-
- { renderFieldValue( value ) }
-
-
- ) ) }
-
-
- { isPreviewModalOpen && previewFile && onModalStateChange && (
-
-
-
- ) }
-
-
- { __( 'Are you sure you want to mark this response as spam?', 'jetpack-forms' ) }
-
-
- { response.status === 'spam' && (
- { __( 'Spam responses are moved to trash after 15 days.', 'jetpack-forms' ) }
- ) }
- { response.status === 'trash' && (
-
- { sprintf(
- /* translators: %d number of days. */
- _n(
- 'Items in trash are permanently deleted after %d day.',
- 'Items in trash are permanently deleted after %d days.',
- emptyTrashDays,
- 'jetpack-forms'
- ),
- emptyTrashDays
- ) }
-
- ) }
- >
- );
-};
-
-export default ResponseView;
+export { ResponseViewBody };
export { ResponseMobileView };
+export { SingleResponseView };
diff --git a/projects/packages/forms/src/dashboard/components/response-view/mobile.tsx b/projects/packages/forms/src/dashboard/components/response-view/mobile.tsx
index 2c7cd448a8aa8..20f36c03503a1 100644
--- a/projects/packages/forms/src/dashboard/components/response-view/mobile.tsx
+++ b/projects/packages/forms/src/dashboard/components/response-view/mobile.tsx
@@ -2,53 +2,44 @@ import {
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalHStack as HStack,
} from '@wordpress/components';
-import { useState, useMemo, useCallback } from '@wordpress/element';
+import { store as coreStore } from '@wordpress/core-data';
+import { useSelect } from '@wordpress/data';
+import { useState, useCallback } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
-import { getItemId } from '../../inbox/utils';
+import { FormResponse } from '../../../types';
+import useResponseNavigation from '../../hooks/use-response-navigation';
import ResponseActions from '../response-actions';
import ResponseNavigation from '../response-navigation';
-import ResponseView from './index';
+import { ResponseViewBody } from './index';
/**
* Component wrapper for InboxResponse in DataViews modal
* Renders response with navigation in modal header for mobile view
- * @param {object} props - The props object.
- * @param {Array} props.data - The responses list array.
- * @param {object} props.response - The response item.
- * @param {Function} props.closeModal - Function to close the DataViews modal.
+ * @param {object} props - The props object.
+ * @param {FormResponse} props.response - The response item.
+ * @param {Function} props.closeModal - Function to close the DataViews modal.
* @return {import('react').JSX.Element} The DataViews component.
*/
-const ResponseMobileView = ( { response, data, closeModal } ) => {
- const [ currentResponse, setCurrentResponse ] = useState( response );
+const ResponseMobileView = ( { response, closeModal } ) => {
+ const [ currentResponseId, setCurrentResponseId ] = useState( response.id );
- const currentIndex = useMemo(
- () =>
- currentResponse && data
- ? data.findIndex( item => getItemId( item ) === getItemId( currentResponse ) )
- : -1,
- [ currentResponse, data ]
+ const responseRecord = useSelect(
+ select =>
+ select( coreStore ).getEditedEntityRecord(
+ 'postType',
+ 'feedback',
+ currentResponseId
+ ) as unknown as FormResponse,
+ [ currentResponseId ]
);
+ // Use the navigation hook
+ const navigation = useResponseNavigation( {
+ onChangeSelection: null,
+ record: responseRecord,
+ setRecord: record => setCurrentResponseId( record.id ),
+ } );
- const hasNext = currentIndex >= 0 && currentIndex < ( data?.length ?? 0 ) - 1;
- const hasPrevious = currentIndex > 0;
-
- const handleNext = useCallback( () => {
- if ( hasNext && data && currentIndex >= 0 ) {
- const nextItem = data[ currentIndex + 1 ];
- if ( nextItem ) {
- setCurrentResponse( nextItem );
- }
- }
- }, [ hasNext, data, currentIndex ] );
-
- const handlePrevious = useCallback( () => {
- if ( hasPrevious && data && currentIndex >= 0 ) {
- const prevItem = data[ currentIndex - 1 ];
- if ( prevItem ) {
- setCurrentResponse( prevItem );
- }
- }
- }, [ hasPrevious, data, currentIndex ] );
+ const { hasNext, hasPrevious, handleNext, handlePrevious } = navigation;
// Action complete handler is a bit different on mobile view.
// We don't close the modal if the response hasn't changed status (read/unread toggle)
@@ -56,7 +47,6 @@ const ResponseMobileView = ( { response, data, closeModal } ) => {
const handleActionComplete = useCallback(
actionedResponse => {
if ( actionedResponse && actionedResponse.status === response.status ) {
- setCurrentResponse( actionedResponse );
return;
}
closeModal?.();
@@ -79,7 +69,7 @@ const ResponseMobileView = ( { response, data, closeModal } ) => {
justify="space-between"
className="jp-forms__inbox__response-mobile__header-actions"
>
-
+
{
/>
-
+
);
};
diff --git a/projects/packages/forms/src/dashboard/components/response-view/single.tsx b/projects/packages/forms/src/dashboard/components/response-view/single.tsx
new file mode 100644
index 0000000000000..41515efe68ef9
--- /dev/null
+++ b/projects/packages/forms/src/dashboard/components/response-view/single.tsx
@@ -0,0 +1,123 @@
+import {
+ // eslint-disable-next-line @wordpress/no-unsafe-wp-apis
+ __experimentalHStack as HStack,
+ Modal,
+} from '@wordpress/components';
+import { useCallback, useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import useResponseNavigation from '../../hooks/use-response-navigation';
+import ResponseActions from '../response-actions';
+import ResponseNavigation from '../response-navigation';
+import { ResponseViewBody } from './index';
+
+/**
+ * Single response component for dataviews.
+ * It might return a modal when viewport is resized to mobile.
+ * @param {object} props - The props object.
+ * @param {FormResponse} props.sidePanelItem - The side panel item.
+ * @param {Function} props.setSidePanelItem - The function to set the side panel item.
+ * @param {boolean} props.isLoadingData - Whether the data is loading.
+ * @param {boolean} props.isMobile - Whether the view is mobile.
+ * @param {Function} props.onChangeSelection - The function to change the selection.
+ * @param {string[]} props.selection - The selection.
+ * @return {import('react').JSX.Element} The single response component.
+ */
+const SingleResponseView = ( {
+ sidePanelItem,
+ setSidePanelItem,
+ isLoadingData,
+ isMobile,
+ onChangeSelection,
+ selection,
+} ) => {
+ const [ isChildModalOpen, setIsChildModalOpen ] = useState( false );
+
+ const onRequestClose = useCallback( () => {
+ if ( ! isChildModalOpen ) {
+ onChangeSelection?.( [] );
+ }
+ }, [ onChangeSelection, isChildModalOpen ] );
+
+ const handleModalStateChange = useCallback(
+ isOpen => {
+ setIsChildModalOpen( isOpen );
+ },
+ [ setIsChildModalOpen ]
+ );
+
+ const handleActionComplete = useCallback(
+ actionedItem => {
+ // Remove only the actioned item from selection, keep the rest
+ if ( actionedItem?.id && selection ) {
+ const newSelection = selection.filter( id => id !== actionedItem.id );
+ onChangeSelection?.( newSelection );
+ }
+ // if the action is on current response and hasn't changed status,
+ // don't close the modal but update the side panel item
+ if ( actionedItem?.id === sidePanelItem.id && actionedItem.status === sidePanelItem.status ) {
+ setSidePanelItem( actionedItem );
+ }
+ },
+ [ onChangeSelection, selection, sidePanelItem, setSidePanelItem ]
+ );
+
+ // Use the navigation hook
+ const navigation = useResponseNavigation( {
+ onChangeSelection,
+ record: sidePanelItem,
+ setRecord: setSidePanelItem,
+ } );
+
+ if ( ! sidePanelItem ) {
+ return null;
+ }
+
+ // Navigation props to pass to InboxResponse and ResponseNavigation
+ const navigationProps = {
+ hasNext: navigation.hasNext,
+ hasPrevious: navigation.hasPrevious,
+ onNext: navigation.handleNext,
+ onPrevious: navigation.handlePrevious,
+ };
+
+ const contents = (
+
+ );
+
+ if ( ! isMobile ) {
+ return (
+
+
+
+
+
+
+
+
+
+ { contents }
+
+ );
+ }
+
+ return (
+
+
+
+ >
+ }
+ >
+ { contents }
+
+ );
+};
+export default SingleResponseView;
diff --git a/projects/packages/forms/src/dashboard/inbox/dataviews/index.js b/projects/packages/forms/src/dashboard/inbox/dataviews/index.js
index 60a9a77df7a14..b90a564d45ffc 100644
--- a/projects/packages/forms/src/dashboard/inbox/dataviews/index.js
+++ b/projects/packages/forms/src/dashboard/inbox/dataviews/index.js
@@ -3,7 +3,6 @@
*/
import {
ExternalLink,
- Modal,
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalHStack as HStack,
} from '@wordpress/components';
@@ -19,11 +18,8 @@ import { useSearchParams } from 'react-router';
* Internal dependencies
*/
import InboxStatusToggle from '../../components/inbox-status-toggle';
-import ResponseActions from '../../components/response-actions';
-import ResponseNavigation from '../../components/response-navigation';
-import ResponseView, { ResponseMobileView } from '../../components/response-view';
+import { ResponseMobileView, SingleResponseView } from '../../components/response-view';
import useInboxData from '../../hooks/use-inbox-data';
-import useResponseNavigation from '../../hooks/use-response-navigation';
import EmptyResponses from '../empty-responses';
import { getPath, getItemId } from '../utils.js';
import {
@@ -308,9 +304,7 @@ export default function InboxView() {
...viewAction,
RenderModal: ( { items, closeModal } ) => {
const [ item ] = items;
- return (
-
- );
+ return
;
},
hideModalHeader: true,
} );
@@ -326,7 +320,7 @@ export default function InboxView() {
} );
}
return _actions;
- }, [ isMobile, onChangeSelection, selection, records ] );
+ }, [ isMobile, onChangeSelection, selection ] );
const resetPage = useCallback( () => {
view.page = 1;
@@ -357,7 +351,7 @@ export default function InboxView() {
empty={
}
/>
-
);
}
-
-const SingleResponse = ( {
- sidePanelItem,
- setSidePanelItem,
- isLoadingData,
- isMobile,
- onChangeSelection,
- selection,
-} ) => {
- const [ isChildModalOpen, setIsChildModalOpen ] = useState( false );
-
- const onRequestClose = useCallback( () => {
- if ( ! isChildModalOpen ) {
- onChangeSelection( [] );
- }
- }, [ onChangeSelection, isChildModalOpen ] );
-
- const handleModalStateChange = useCallback(
- isOpen => {
- setIsChildModalOpen( isOpen );
- },
- [ setIsChildModalOpen ]
- );
-
- const handleActionComplete = useCallback(
- actionedItem => {
- // Remove only the actioned item from selection, keep the rest
- if ( actionedItem?.id && selection ) {
- const newSelection = selection.filter( id => id !== actionedItem.id );
- onChangeSelection( newSelection );
- }
- // if the action is on current response and hasn't changed status,
- // don't close the modal but update the side panel item
- if ( actionedItem?.id === sidePanelItem.id && actionedItem.status === sidePanelItem.status ) {
- setSidePanelItem( actionedItem );
- }
- },
- [ onChangeSelection, selection, sidePanelItem, setSidePanelItem ]
- );
-
- // Use the navigation hook
- const navigation = useResponseNavigation( {
- onChangeSelection,
- record: sidePanelItem,
- setRecord: setSidePanelItem,
- } );
-
- if ( ! sidePanelItem ) {
- return null;
- }
-
- // Navigation props to pass to InboxResponse and ResponseNavigation
- const navigationProps = {
- hasNext: navigation.hasNext,
- hasPrevious: navigation.hasPrevious,
- onNext: navigation.handleNext,
- onPrevious: navigation.handlePrevious,
- };
-
- const contents = (
-
- );
-
- if ( ! isMobile ) {
- return (
-
-
-
-
-
-
-
-
-
- { contents }
-
- );
- }
-
- return (
-
-
-
- >
- }
- >
- { contents }
-
- );
-};
diff --git a/projects/packages/forms/tests/js/dashboard/hooks/use-response-navigation.test.jsx b/projects/packages/forms/tests/js/dashboard/hooks/use-response-navigation.test.jsx
new file mode 100644
index 0000000000000..6abb7914e9530
--- /dev/null
+++ b/projects/packages/forms/tests/js/dashboard/hooks/use-response-navigation.test.jsx
@@ -0,0 +1,464 @@
+/**
+ * External dependencies
+ */
+import { renderHook } from '@testing-library/react';
+/**
+ * Internal dependencies
+ */
+import useInboxData from '../../../../src/dashboard/hooks/use-inbox-data';
+import useResponseNavigation from '../../../../src/dashboard/hooks/use-response-navigation';
+import { getItemId } from '../../../../src/dashboard/inbox/utils';
+
+// Mock dependencies
+jest.mock( '../../../../src/dashboard/hooks/use-inbox-data' );
+jest.mock( '../../../../src/dashboard/inbox/utils' );
+
+const mockedUseInboxData = useInboxData;
+const mockedGetItemId = getItemId;
+
+describe( 'useResponseNavigation', () => {
+ const mockSetRecord = jest.fn();
+ const mockOnChangeSelection = jest.fn();
+
+ // Mock response data
+ const mockRecords = [
+ {
+ id: 1,
+ status: 'publish',
+ date: '2025-01-01T00:00:00',
+ date_gmt: '2025-01-01T00:00:00',
+ author_name: 'John Doe',
+ author_email: 'john@example.com',
+ author_url: 'https://example.com',
+ author_avatar: 'https://example.com/avatar.jpg',
+ ip: '127.0.0.1',
+ entry_title: 'Contact Form',
+ entry_permalink: 'https://example.com/contact',
+ has_file: false,
+ is_unread: true,
+ fields: { name: 'John', email: 'john@example.com' },
+ },
+ {
+ id: 2,
+ status: 'publish',
+ date: '2025-01-02T00:00:00',
+ date_gmt: '2025-01-02T00:00:00',
+ author_name: 'Jane Smith',
+ author_email: 'jane@example.com',
+ author_url: 'https://example.com',
+ author_avatar: 'https://example.com/avatar2.jpg',
+ ip: '127.0.0.2',
+ entry_title: 'Contact Form',
+ entry_permalink: 'https://example.com/contact',
+ has_file: false,
+ is_unread: true,
+ fields: { name: 'Jane', email: 'jane@example.com' },
+ },
+ {
+ id: 3,
+ status: 'publish',
+ date: '2025-01-03T00:00:00',
+ date_gmt: '2025-01-03T00:00:00',
+ author_name: 'Bob Johnson',
+ author_email: 'bob@example.com',
+ author_url: 'https://example.com',
+ author_avatar: 'https://example.com/avatar3.jpg',
+ ip: '127.0.0.3',
+ entry_title: 'Contact Form',
+ entry_permalink: 'https://example.com/contact',
+ has_file: false,
+ is_unread: false,
+ fields: { name: 'Bob', email: 'bob@example.com' },
+ },
+ ];
+
+ beforeEach( () => {
+ jest.clearAllMocks();
+
+ // Setup default mock implementations
+ mockedGetItemId.mockImplementation( item => item?.id?.toString() ?? '' );
+
+ mockedUseInboxData.mockReturnValue( {
+ records: mockRecords,
+ totalItemsInbox: 3,
+ totalItemsSpam: 0,
+ totalItemsTrash: 0,
+ isLoadingData: false,
+ totalItems: 3,
+ totalPages: 1,
+ selectedResponsesCount: 0,
+ setSelectedResponses: jest.fn(),
+ statusFilter: 'draft,publish',
+ currentStatus: 'inbox',
+ currentQuery: {},
+ setCurrentQuery: jest.fn(),
+ filterOptions: {},
+ } );
+ } );
+
+ describe( 'Navigation state', () => {
+ it( 'should calculate correct currentIndex for first item', () => {
+ const { result } = renderHook( () =>
+ useResponseNavigation( {
+ onChangeSelection: mockOnChangeSelection,
+ record: mockRecords[ 0 ],
+ setRecord: mockSetRecord,
+ } )
+ );
+
+ expect( result.current.currentIndex ).toBe( 0 );
+ expect( result.current.hasNext ).toBe( true );
+ expect( result.current.hasPrevious ).toBe( false );
+ } );
+
+ it( 'should calculate correct currentIndex for middle item', () => {
+ const { result } = renderHook( () =>
+ useResponseNavigation( {
+ onChangeSelection: mockOnChangeSelection,
+ record: mockRecords[ 1 ],
+ setRecord: mockSetRecord,
+ } )
+ );
+
+ expect( result.current.currentIndex ).toBe( 1 );
+ expect( result.current.hasNext ).toBe( true );
+ expect( result.current.hasPrevious ).toBe( true );
+ } );
+
+ it( 'should calculate correct currentIndex for last item', () => {
+ const { result } = renderHook( () =>
+ useResponseNavigation( {
+ onChangeSelection: mockOnChangeSelection,
+ record: mockRecords[ 2 ],
+ setRecord: mockSetRecord,
+ } )
+ );
+
+ expect( result.current.currentIndex ).toBe( 2 );
+ expect( result.current.hasNext ).toBe( false );
+ expect( result.current.hasPrevious ).toBe( true );
+ } );
+
+ it( 'should return -1 as currentIndex when record is not found', () => {
+ const unknownRecord = {
+ ...mockRecords[ 0 ],
+ id: 999,
+ };
+
+ const { result } = renderHook( () =>
+ useResponseNavigation( {
+ onChangeSelection: mockOnChangeSelection,
+ record: unknownRecord,
+ setRecord: mockSetRecord,
+ } )
+ );
+
+ expect( result.current.currentIndex ).toBe( -1 );
+ expect( result.current.hasNext ).toBe( false );
+ expect( result.current.hasPrevious ).toBe( false );
+ } );
+
+ it( 'should handle empty records array', () => {
+ mockedUseInboxData.mockReturnValue( {
+ records: [],
+ totalItemsInbox: 0,
+ totalItemsSpam: 0,
+ totalItemsTrash: 0,
+ isLoadingData: false,
+ totalItems: 0,
+ totalPages: 0,
+ selectedResponsesCount: 0,
+ setSelectedResponses: jest.fn(),
+ statusFilter: 'draft,publish',
+ currentStatus: 'inbox',
+ currentQuery: {},
+ setCurrentQuery: jest.fn(),
+ filterOptions: {},
+ } );
+
+ const { result } = renderHook( () =>
+ useResponseNavigation( {
+ onChangeSelection: mockOnChangeSelection,
+ record: mockRecords[ 0 ],
+ setRecord: mockSetRecord,
+ } )
+ );
+
+ expect( result.current.currentIndex ).toBe( -1 );
+ expect( result.current.hasNext ).toBe( false );
+ expect( result.current.hasPrevious ).toBe( false );
+ } );
+
+ it( 'should handle single record in array', () => {
+ mockedUseInboxData.mockReturnValue( {
+ records: [ mockRecords[ 0 ] ],
+ totalItemsInbox: 1,
+ totalItemsSpam: 0,
+ totalItemsTrash: 0,
+ isLoadingData: false,
+ totalItems: 1,
+ totalPages: 1,
+ selectedResponsesCount: 0,
+ setSelectedResponses: jest.fn(),
+ statusFilter: 'draft,publish',
+ currentStatus: 'inbox',
+ currentQuery: {},
+ setCurrentQuery: jest.fn(),
+ filterOptions: {},
+ } );
+
+ const { result } = renderHook( () =>
+ useResponseNavigation( {
+ onChangeSelection: mockOnChangeSelection,
+ record: mockRecords[ 0 ],
+ setRecord: mockSetRecord,
+ } )
+ );
+
+ expect( result.current.currentIndex ).toBe( 0 );
+ expect( result.current.hasNext ).toBe( false );
+ expect( result.current.hasPrevious ).toBe( false );
+ } );
+ } );
+
+ describe( 'Navigation handlers', () => {
+ it( 'should navigate to next item correctly', () => {
+ const { result } = renderHook( () =>
+ useResponseNavigation( {
+ onChangeSelection: mockOnChangeSelection,
+ record: mockRecords[ 0 ],
+ setRecord: mockSetRecord,
+ } )
+ );
+
+ result.current.handleNext();
+
+ expect( mockSetRecord ).toHaveBeenCalledWith( mockRecords[ 1 ] );
+ expect( mockOnChangeSelection ).toHaveBeenCalledWith( [ '2' ] );
+ } );
+
+ it( 'should navigate to previous item correctly', () => {
+ const { result } = renderHook( () =>
+ useResponseNavigation( {
+ onChangeSelection: mockOnChangeSelection,
+ record: mockRecords[ 1 ],
+ setRecord: mockSetRecord,
+ } )
+ );
+
+ result.current.handlePrevious();
+
+ expect( mockSetRecord ).toHaveBeenCalledWith( mockRecords[ 0 ] );
+ expect( mockOnChangeSelection ).toHaveBeenCalledWith( [ '1' ] );
+ } );
+
+ it( 'should not navigate next when at last item', () => {
+ const { result } = renderHook( () =>
+ useResponseNavigation( {
+ onChangeSelection: mockOnChangeSelection,
+ record: mockRecords[ 2 ],
+ setRecord: mockSetRecord,
+ } )
+ );
+
+ result.current.handleNext();
+
+ expect( mockSetRecord ).not.toHaveBeenCalled();
+ expect( mockOnChangeSelection ).not.toHaveBeenCalled();
+ } );
+
+ it( 'should not navigate previous when at first item', () => {
+ const { result } = renderHook( () =>
+ useResponseNavigation( {
+ onChangeSelection: mockOnChangeSelection,
+ record: mockRecords[ 0 ],
+ setRecord: mockSetRecord,
+ } )
+ );
+
+ result.current.handlePrevious();
+
+ expect( mockSetRecord ).not.toHaveBeenCalled();
+ expect( mockOnChangeSelection ).not.toHaveBeenCalled();
+ } );
+
+ it( 'should not navigate when currentIndex is -1', () => {
+ const unknownRecord = {
+ ...mockRecords[ 0 ],
+ id: 999,
+ };
+
+ const { result } = renderHook( () =>
+ useResponseNavigation( {
+ onChangeSelection: mockOnChangeSelection,
+ record: unknownRecord,
+ setRecord: mockSetRecord,
+ } )
+ );
+
+ result.current.handleNext();
+ result.current.handlePrevious();
+
+ expect( mockSetRecord ).not.toHaveBeenCalled();
+ expect( mockOnChangeSelection ).not.toHaveBeenCalled();
+ } );
+
+ it( 'should handle null onChangeSelection callback', () => {
+ const { result } = renderHook( () =>
+ useResponseNavigation( {
+ onChangeSelection: null,
+ record: mockRecords[ 0 ],
+ setRecord: mockSetRecord,
+ } )
+ );
+
+ // Should not throw when onChangeSelection is null
+ expect( () => result.current.handleNext() ).not.toThrow();
+ expect( mockSetRecord ).toHaveBeenCalledWith( mockRecords[ 1 ] );
+ } );
+
+ it( 'should handle undefined onChangeSelection callback', () => {
+ const { result } = renderHook( () =>
+ useResponseNavigation( {
+ onChangeSelection: undefined,
+ record: mockRecords[ 0 ],
+ setRecord: mockSetRecord,
+ } )
+ );
+
+ // Should not throw when onChangeSelection is undefined
+ expect( () => result.current.handleNext() ).not.toThrow();
+ expect( mockSetRecord ).toHaveBeenCalledWith( mockRecords[ 1 ] );
+ } );
+ } );
+
+ describe( 'Edge cases', () => {
+ it( 'should handle records being null', () => {
+ mockedUseInboxData.mockReturnValue( {
+ records: null,
+ totalItemsInbox: 0,
+ totalItemsSpam: 0,
+ totalItemsTrash: 0,
+ isLoadingData: true,
+ totalItems: 0,
+ totalPages: 0,
+ selectedResponsesCount: 0,
+ setSelectedResponses: jest.fn(),
+ statusFilter: 'draft,publish',
+ currentStatus: 'inbox',
+ currentQuery: {},
+ setCurrentQuery: jest.fn(),
+ filterOptions: {},
+ } );
+
+ const { result } = renderHook( () =>
+ useResponseNavigation( {
+ onChangeSelection: mockOnChangeSelection,
+ record: mockRecords[ 0 ],
+ setRecord: mockSetRecord,
+ } )
+ );
+
+ expect( result.current.currentIndex ).toBe( -1 );
+ expect( result.current.hasNext ).toBe( false );
+ expect( result.current.hasPrevious ).toBe( false );
+
+ // Navigation should not work with null records
+ result.current.handleNext();
+ result.current.handlePrevious();
+ expect( mockSetRecord ).not.toHaveBeenCalled();
+ } );
+
+ it( 'should handle record being null', () => {
+ const { result } = renderHook( () =>
+ useResponseNavigation( {
+ onChangeSelection: mockOnChangeSelection,
+ record: null,
+ setRecord: mockSetRecord,
+ } )
+ );
+
+ expect( result.current.currentIndex ).toBe( -1 );
+ expect( result.current.hasNext ).toBe( false );
+ expect( result.current.hasPrevious ).toBe( false );
+ } );
+
+ it( 'should update navigation state when record changes', () => {
+ const { result, rerender } = renderHook(
+ ( { record } ) =>
+ useResponseNavigation( {
+ onChangeSelection: mockOnChangeSelection,
+ record,
+ setRecord: mockSetRecord,
+ } ),
+ {
+ initialProps: { record: mockRecords[ 0 ] },
+ }
+ );
+
+ expect( result.current.currentIndex ).toBe( 0 );
+ expect( result.current.hasNext ).toBe( true );
+ expect( result.current.hasPrevious ).toBe( false );
+
+ // Change to last record
+ rerender( { record: mockRecords[ 2 ] } );
+
+ expect( result.current.currentIndex ).toBe( 2 );
+ expect( result.current.hasNext ).toBe( false );
+ expect( result.current.hasPrevious ).toBe( true );
+ } );
+
+ it( 'should update navigation state when records list changes', () => {
+ const { result, rerender } = renderHook( () =>
+ useResponseNavigation( {
+ onChangeSelection: mockOnChangeSelection,
+ record: mockRecords[ 1 ],
+ setRecord: mockSetRecord,
+ } )
+ );
+
+ expect( result.current.currentIndex ).toBe( 1 );
+
+ // Update records list - remove first item
+ mockedUseInboxData.mockReturnValue( {
+ records: mockRecords.slice( 1 ),
+ totalItemsInbox: 2,
+ totalItemsSpam: 0,
+ totalItemsTrash: 0,
+ isLoadingData: false,
+ totalItems: 2,
+ totalPages: 1,
+ selectedResponsesCount: 0,
+ setSelectedResponses: jest.fn(),
+ statusFilter: 'draft,publish',
+ currentStatus: 'inbox',
+ currentQuery: {},
+ setCurrentQuery: jest.fn(),
+ filterOptions: {},
+ } );
+
+ rerender();
+
+ // Record that was at index 1 is now at index 0
+ expect( result.current.currentIndex ).toBe( 0 );
+ expect( result.current.hasPrevious ).toBe( false );
+ } );
+
+ it( 'should use getItemId for comparison', () => {
+ // Mock getItemId to use custom logic
+ mockedGetItemId.mockImplementation( item => ( item ? `custom_${ item.id }` : '' ) );
+
+ const { result } = renderHook( () =>
+ useResponseNavigation( {
+ onChangeSelection: mockOnChangeSelection,
+ record: mockRecords[ 1 ],
+ setRecord: mockSetRecord,
+ } )
+ );
+
+ // Should still find the correct index using custom getItemId
+ expect( result.current.currentIndex ).toBe( 1 );
+ expect( mockedGetItemId ).toHaveBeenCalled();
+ } );
+ } );
+} );