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 ( - - ); - } ) - : '-' } -
- ); - } - - // Emails - const emailRegEx = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i; - if ( emailRegEx.test( value ) ) { - return ( -
- { value } - -
- ); - } - - // Phone numbers - if ( isLikelyPhoneNumber( value ) ) { - return ( -
- { value } -
- ); - } - - 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(); + } ); + } ); +} );