diff --git a/projects/packages/forms/changelog/update-forms-view-actions-modal b/projects/packages/forms/changelog/update-forms-view-actions-modal
new file mode 100644
index 0000000000000..0cf8bffec06fd
--- /dev/null
+++ b/projects/packages/forms/changelog/update-forms-view-actions-modal
@@ -0,0 +1,4 @@
+Significance: minor
+Type: changed
+
+Forms: move view actions to modal header on mobile
diff --git a/projects/packages/forms/src/dashboard/components/response-actions/index.tsx b/projects/packages/forms/src/dashboard/components/response-actions/index.tsx
new file mode 100644
index 0000000000000..8493f56a5c355
--- /dev/null
+++ b/projects/packages/forms/src/dashboard/components/response-actions/index.tsx
@@ -0,0 +1,204 @@
+/**
+ * External dependencies
+ */
+import { Button } from '@wordpress/components';
+import { useRegistry } from '@wordpress/data';
+import { useCallback, useState } from '@wordpress/element';
+/**
+ * Internal dependencies
+ */
+import {
+ markAsSpamAction,
+ markAsNotSpamAction,
+ moveToTrashAction,
+ restoreAction,
+ deleteAction,
+ markAsReadAction,
+ markAsUnreadAction,
+} from '../../inbox/dataviews/actions';
+/**
+ * Types
+ */
+import type { FormResponse } from '../../../types';
+
+type ResponseNavigationProps = {
+ onActionComplete?: ( FormResponse ) => void;
+ response: FormResponse;
+};
+
+const ResponseActions = ( {
+ onActionComplete,
+ response,
+}: ResponseNavigationProps ): JSX.Element => {
+ const [ isMarkingAsSpam, setIsMarkingAsSpam ] = useState( false );
+ const [ isMarkingAsNotSpam, setIsMarkingAsNotSpam ] = useState( false );
+ const [ isMovingToTrash, setIsMovingToTrash ] = useState( false );
+ const [ isRestoring, setIsRestoring ] = useState( false );
+ const [ isDeleting, setIsDeleting ] = useState( false );
+ const [ isTogglingReadStatus, setIsTogglingReadStatus ] = useState( false );
+
+ const registry = useRegistry();
+
+ const handleMarkAsSpam = useCallback( async () => {
+ onActionComplete?.( response );
+ setIsMarkingAsSpam( true );
+ await markAsSpamAction.callback( [ response ], { registry } );
+ setIsMarkingAsSpam( false );
+ }, [ response, registry, onActionComplete ] );
+
+ const handleMarkAsNotSpam = useCallback( async () => {
+ onActionComplete?.( response );
+ setIsMarkingAsNotSpam( true );
+ await markAsNotSpamAction.callback( [ response ], { registry } );
+ setIsMarkingAsNotSpam( false );
+ }, [ response, registry, onActionComplete ] );
+
+ const handleMoveToTrash = useCallback( async () => {
+ onActionComplete?.( response );
+ setIsMovingToTrash( true );
+ await moveToTrashAction.callback( [ response ], { registry } );
+ setIsMovingToTrash( false );
+ }, [ response, registry, onActionComplete ] );
+
+ const handleRestore = useCallback( async () => {
+ onActionComplete?.( response );
+ setIsRestoring( true );
+ await restoreAction.callback( [ response ], { registry } );
+ setIsRestoring( false );
+ }, [ response, registry, onActionComplete ] );
+
+ const handleDelete = useCallback( async () => {
+ onActionComplete?.( response );
+ setIsDeleting( true );
+ await deleteAction.callback( [ response ], { registry } );
+ setIsDeleting( false );
+ }, [ response, registry, onActionComplete ] );
+
+ const handleMarkAsRead = useCallback( async () => {
+ setIsTogglingReadStatus( true );
+ await markAsReadAction.callback( [ response ], { registry } );
+ setIsTogglingReadStatus( false );
+ onActionComplete?.( { ...response, is_unread: false } );
+ }, [ response, registry, onActionComplete ] );
+
+ const handleMarkAsUnread = useCallback( async () => {
+ setIsTogglingReadStatus( true );
+ await markAsUnreadAction.callback( [ response ], { registry } );
+ setIsTogglingReadStatus( false );
+ onActionComplete?.( { ...response, is_unread: true } );
+ }, [ response, registry, onActionComplete ] );
+
+ const readUnreadButtons = (
+ <>
+ { response.is_unread && (
+
+ ) }
+ { ! response.is_unread && (
+
+ ) }
+ >
+ );
+
+ switch ( response.status ) {
+ case 'spam':
+ return (
+
+ { readUnreadButtons }
+
+
+
+ );
+
+ case 'trash':
+ return (
+
+ { readUnreadButtons }
+
+
+
+ );
+
+ default: // 'publish' (inbox) or any other status
+ return (
+
+ { readUnreadButtons }
+
+
+
+ );
+ }
+};
+
+export default ResponseActions;
diff --git a/projects/packages/forms/src/dashboard/components/response-navigation/index.tsx b/projects/packages/forms/src/dashboard/components/response-navigation/index.tsx
new file mode 100644
index 0000000000000..89646d8264ab2
--- /dev/null
+++ b/projects/packages/forms/src/dashboard/components/response-navigation/index.tsx
@@ -0,0 +1,63 @@
+/**
+ * External dependencies
+ */
+import { Button } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+import { close, chevronLeft, chevronRight } from '@wordpress/icons';
+
+type ResponseNavigationProps = {
+ hasNext: boolean;
+ hasPrevious: boolean;
+ onClose: ( () => void ) | null;
+ onNext: () => void;
+ onPrevious: () => void;
+};
+
+const ResponseNavigation = ( {
+ hasNext,
+ hasPrevious,
+ onClose,
+ onNext,
+ onPrevious,
+}: ResponseNavigationProps ): JSX.Element => {
+ return (
+
+ { onPrevious && (
+
+ ) }
+ { onNext && (
+
+ ) }
+ { onClose && (
+
+ ) }
+
+ );
+};
+
+export default ResponseNavigation;
diff --git a/projects/packages/forms/src/dashboard/inbox/dataviews/actions.js b/projects/packages/forms/src/dashboard/inbox/dataviews/actions.js
index 2baf911a1867e..134b7dc3b9958 100644
--- a/projects/packages/forms/src/dashboard/inbox/dataviews/actions.js
+++ b/projects/packages/forms/src/dashboard/inbox/dataviews/actions.js
@@ -6,7 +6,6 @@ import { seen, unseen, trash, backup, commentContent } from '@wordpress/icons';
import { store as noticesStore } from '@wordpress/notices';
import { notSpam, spam } from '../../icons';
import { store as dashboardStore } from '../../store';
-import InboxResponse from '../response';
import { updateMenuCounter, updateMenuCounterOptimistically } from '../utils';
export const BULK_ACTIONS = {
@@ -20,10 +19,6 @@ export const viewAction = {
isPrimary: true,
label: __( 'View response', 'jetpack-forms' ),
modalHeader: __( 'Response', 'jetpack-forms' ),
- RenderModal: ( { items } ) => {
- const [ item ] = items;
- return ;
- },
};
// TODO: We should probably have better error messages in case of failure.
diff --git a/projects/packages/forms/src/dashboard/inbox/dataviews/index.js b/projects/packages/forms/src/dashboard/inbox/dataviews/index.js
index 59eda66421561..3eec47e377513 100644
--- a/projects/packages/forms/src/dashboard/inbox/dataviews/index.js
+++ b/projects/packages/forms/src/dashboard/inbox/dataviews/index.js
@@ -20,6 +20,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 useInboxData from '../../hooks/use-inbox-data';
import EmptyResponses from '../empty-responses';
import InboxResponse from '../response';
@@ -340,10 +342,27 @@ export default function InboxView() {
deleteAction,
];
if ( isMobile ) {
- _actions.unshift( viewAction );
+ _actions.unshift( {
+ ...viewAction,
+ RenderModal: ( { items, closeModal } ) => {
+ const [ item ] = items;
+ return ;
+ },
+ hideModalHeader: true,
+ } );
+ } else {
+ _actions.unshift( {
+ ...viewAction,
+ callback( items ) {
+ const [ item ] = items;
+ const selectedId = item.id.toString();
+ const selectionWithoutSelectedId = selection.filter( id => id !== selectedId );
+ onChangeSelection( [ ...selectionWithoutSelectedId, selectedId ] );
+ },
+ } );
}
return _actions;
- }, [ isMobile ] );
+ }, [ isMobile, onChangeSelection, selection, data ] );
const resetPage = useCallback( () => {
view.page = 1;
@@ -387,46 +406,105 @@ export default function InboxView() {
);
}
-const SingleResponse = ( {
- sidePanelItem,
- setSidePanelItem,
- isLoadingData,
- isMobile,
- data,
- onChangeSelection,
- selection,
-} ) => {
- const [ isChildModalOpen, setIsChildModalOpen ] = useState( false );
+/**
+ * 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.
+ * @return {import('react').JSX.Element} The DataViews component.
+ */
+const InboxResponseMobile = ( { response, data, closeModal } ) => {
+ const [ currentResponse, setCurrentResponse ] = useState( response );
- const onRequestClose = useCallback( () => {
- if ( ! isChildModalOpen ) {
- onChangeSelection( [] );
+ const currentIndex = useMemo(
+ () =>
+ currentResponse && data
+ ? data.findIndex( item => getItemId( item ) === getItemId( currentResponse ) )
+ : -1,
+ [ currentResponse, data ]
+ );
+
+ 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 );
+ }
}
- }, [ onChangeSelection, isChildModalOpen ] );
+ }, [ hasNext, data, currentIndex ] );
- const handleModalStateChange = useCallback(
- isOpen => {
- setIsChildModalOpen( isOpen );
- },
- [ setIsChildModalOpen ]
- );
+ const handlePrevious = useCallback( () => {
+ if ( hasPrevious && data && currentIndex >= 0 ) {
+ const prevItem = data[ currentIndex - 1 ];
+ if ( prevItem ) {
+ setCurrentResponse( prevItem );
+ }
+ }
+ }, [ hasPrevious, data, currentIndex ] );
+ // 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)
+ // and we don't change nor mess with the selection.
const handleActionComplete = useCallback(
- actionedItemId => {
- // Remove only the actioned item from selection, keep the rest
- if ( actionedItemId && selection ) {
- const newSelection = selection.filter( id => id !== actionedItemId );
- onChangeSelection( newSelection );
+ actionedResponse => {
+ if ( actionedResponse && actionedResponse.status === response.status ) {
+ setCurrentResponse( actionedResponse );
+ return;
}
+ closeModal?.();
},
- [ onChangeSelection, selection ]
+ [ closeModal, response ]
+ );
+
+ return (
+
+
+
+ { __( 'Response', 'jetpack-forms' ) }
+
+
+
+
+
+
+
+
+ );
+};
+
+const useResponseNavigation = ( { data, onChangeSelection, sidePanelItem, setSidePanelItem } ) => {
+ const currentIndex = useMemo(
+ () =>
+ sidePanelItem && data
+ ? data.findIndex( item => getItemId( item ) === getItemId( sidePanelItem ) )
+ : -1,
+ [ sidePanelItem, data ]
);
- // Navigation logic
- const currentIndex =
- sidePanelItem && data
- ? data.findIndex( item => getItemId( item ) === getItemId( sidePanelItem ) )
- : -1;
const hasNext = currentIndex >= 0 && currentIndex < ( data?.length ?? 0 ) - 1;
const hasPrevious = currentIndex > 0;
@@ -450,31 +528,102 @@ const SingleResponse = ( {
}
}, [ hasPrevious, data, currentIndex, setSidePanelItem, onChangeSelection ] );
+ return {
+ currentIndex,
+ hasNext,
+ hasPrevious,
+ handleNext,
+ handlePrevious,
+ };
+};
+
+const SingleResponse = ( {
+ sidePanelItem,
+ setSidePanelItem,
+ isLoadingData,
+ isMobile,
+ data,
+ 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( {
+ data,
+ onChangeSelection,
+ sidePanelItem,
+ 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/src/dashboard/inbox/response.js b/projects/packages/forms/src/dashboard/inbox/response.js
index 964e90232f627..8521218dc5107 100644
--- a/projects/packages/forms/src/dashboard/inbox/response.js
+++ b/projects/packages/forms/src/dashboard/inbox/response.js
@@ -14,12 +14,12 @@ import {
__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 { useRegistry, useDispatch } from '@wordpress/data';
+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, close, chevronLeft, chevronRight } from '@wordpress/icons';
+import { download } from '@wordpress/icons';
import clsx from 'clsx';
/**
* Internal dependencies
@@ -27,16 +27,9 @@ import clsx from 'clsx';
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 {
- markAsSpamAction,
- markAsNotSpamAction,
- moveToTrashAction,
- restoreAction,
- deleteAction,
- markAsReadAction,
- markAsUnreadAction,
-} from './dataviews/actions';
import { getPath, updateMenuCounter, updateMenuCounterOptimistically } from './utils';
const getDisplayName = response => {
@@ -198,11 +191,6 @@ const InboxResponse = ( {
const [ isPreviewModalOpen, setIsPreviewModalOpen ] = useState( false );
const [ previewFile, setPreviewFile ] = useState( null );
const [ isImageLoading, setIsImageLoading ] = useState( true );
- const [ isMarkingAsSpam, setIsMarkingAsSpam ] = useState( false );
- const [ isMarkingAsNotSpam, setIsMarkingAsNotSpam ] = useState( false );
- const [ isMovingToTrash, setIsMovingToTrash ] = useState( false );
- const [ isRestoring, setIsRestoring ] = useState( false );
- const [ isDeleting, setIsDeleting ] = useState( false );
const [ hasMarkedSelfAsRead, setHasMarkedSelfAsRead ] = useState( false );
const { editEntityRecord } = useDispatch( 'core' );
@@ -214,8 +202,6 @@ const InboxResponse = ( {
const { isConfirmDialogOpen, onConfirmMarkAsSpam, onCancelMarkAsSpam } =
useMarkAsSpam( response );
- const registry = useRegistry();
-
const ref = useRef( undefined );
const openFilePreview = useCallback(
@@ -244,202 +230,6 @@ const InboxResponse = ( {
}
}, [ onModalStateChange, setIsPreviewModalOpen, setIsImageLoading ] );
- const handleMarkAsSpam = useCallback( async () => {
- setIsMarkingAsSpam( true );
- await markAsSpamAction.callback( [ response ], { registry } );
- setIsMarkingAsSpam( false );
- onActionComplete?.( response.id.toString() );
- }, [ response, registry, onActionComplete ] );
-
- const handleMarkAsNotSpam = useCallback( async () => {
- setIsMarkingAsNotSpam( true );
- await markAsNotSpamAction.callback( [ response ], { registry } );
- setIsMarkingAsNotSpam( false );
- onActionComplete?.( response.id.toString() );
- }, [ response, registry, onActionComplete ] );
-
- const handleMoveToTrash = useCallback( async () => {
- setIsMovingToTrash( true );
- await moveToTrashAction.callback( [ response ], { registry } );
- setIsMovingToTrash( false );
- onActionComplete?.( response.id.toString() );
- }, [ response, registry, onActionComplete ] );
-
- const handleRestore = useCallback( async () => {
- setIsRestoring( true );
- await restoreAction.callback( [ response ], { registry } );
- setIsRestoring( false );
- onActionComplete?.( response.id.toString() );
- }, [ response, registry, onActionComplete ] );
-
- const handleDelete = useCallback( async () => {
- setIsDeleting( true );
- await deleteAction.callback( [ response ], { registry } );
- setIsDeleting( false );
- onActionComplete?.( response.id.toString() );
- }, [ response, registry, onActionComplete ] );
-
- const handleMarkAsRead = useCallback( () => {
- markAsReadAction.callback( [ response ], { registry } );
- }, [ response, registry ] );
-
- const handleMarkAsUnread = useCallback( () => {
- setHasMarkedSelfAsRead( response.id );
- markAsUnreadAction.callback( [ response ], { registry } );
- }, [ response, registry ] );
- const readUnreadButtons = (
- <>
- { response.is_unread && (
-
- ) }
- { ! response.is_unread && (
-
- ) }
- >
- );
-
- const renderActionButtons = () => {
- switch ( response.status ) {
- case 'spam':
- return (
- <>
- { readUnreadButtons }
-
-
- >
- );
-
- case 'trash':
- return (
- <>
- { readUnreadButtons }
-
-
- >
- );
-
- default: // 'publish' (inbox) or any other status
- return (
- <>
- { readUnreadButtons }
-
-
- >
- );
- }
- };
-
- const renderNavigationButtons = () => {
- return (
- <>
- { onPrevious && (
-
- ) }
- { onNext && (
-
- ) }
- { ! isMobile && onClose && (
-
- ) }
- >
- );
- };
-
const renderFieldValue = value => {
if ( isImageSelectField( value ) ) {
return (
@@ -600,10 +390,22 @@ const InboxResponse = ( {
return (
<>
-
- { renderActionButtons() }
- { renderNavigationButtons() }
-
+ { ! isMobile && (
+
+
+
+
+
+
+
+
+ ) }
diff --git a/projects/packages/forms/src/dashboard/inbox/style.scss b/projects/packages/forms/src/dashboard/inbox/style.scss
index 66b181b47c311..3d967a817500a 100644
--- a/projects/packages/forms/src/dashboard/inbox/style.scss
+++ b/projects/packages/forms/src/dashboard/inbox/style.scss
@@ -414,6 +414,18 @@
}
}
+.jp-forms__inbox__response-mobile {
+
+ .jp-forms__inbox__response-mobile__header-heading {
+ font-size: 1.2rem;
+ font-weight: 600;
+ }
+
+ .jp-forms__inbox__response-mobile__header-actions {
+ width: auto;
+ }
+}
+
/*
* We need to make the available canvas 100% tall. Without this,