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..1c64ecbf7f956
--- /dev/null
+++ b/projects/packages/forms/src/dashboard/components/response-actions/index.tsx
@@ -0,0 +1,234 @@
+/**
+ * External dependencies
+ */
+import { Button } from '@wordpress/components';
+import { useRegistry } from '@wordpress/data';
+import { useCallback, useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+/**
+ * Internal dependencies
+ */
+import {
+ markAsSpamAction,
+ markAsNotSpamAction,
+ moveToTrashAction,
+ restoreAction,
+ deleteAction,
+ markAsReadAction,
+ markAsUnreadAction,
+} from '../../inbox/dataviews/actions';
+/**
+ * Types
+ */
+import type { FormResponse } from '../../../types';
+
+type ResponseNavigationProps = {
+ onActionComplete?: ( id: string ) => void;
+ onMarkAsRead?: ( id: number | false ) => void;
+ response: FormResponse;
+ isMobile?: boolean;
+};
+
+const ResponseActions = ( {
+ onActionComplete,
+ onMarkAsRead,
+ response,
+ isMobile = false,
+}: 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 () => {
+ 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( async () => {
+ setIsTogglingReadStatus( true );
+ onMarkAsRead?.( response.id );
+ await markAsReadAction.callback( [ response ], { registry } );
+ setIsTogglingReadStatus( false );
+ }, [ response, registry, onMarkAsRead ] );
+
+ const handleMarkAsUnread = useCallback( async () => {
+ setIsTogglingReadStatus( true );
+ await markAsUnreadAction.callback( [ response ], { registry } );
+ setIsTogglingReadStatus( false );
+ }, [ response, registry ] );
+
+ const variant = isMobile ? 'secondary' : 'tertiary';
+
+ const readUnreadButtons = (
+ <>
+ { response.is_unread && (
+
+ ) }
+ { ! response.is_unread && (
+
+ ) }
+ >
+ );
+
+ let buttons;
+
+ switch ( response.status ) {
+ case 'spam':
+ buttons = (
+ <>
+ { readUnreadButtons }
+
+
+ >
+ );
+ break;
+ case 'trash':
+ buttons = (
+ <>
+ { readUnreadButtons }
+
+
+ >
+ );
+ break;
+
+ default: // 'publish' (inbox) or any other status
+ buttons = (
+ <>
+ { readUnreadButtons }
+
+
+ >
+ );
+ }
+
+ if ( isMobile ) {
+ return
{ buttons }
;
+ }
+ return buttons;
+};
+
+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..81f93a3c604b1
--- /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..2f8815af292a7 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,6 +406,127 @@ export default function InboxView() {
);
}
+/**
+ * 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 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 );
+ }
+ }
+ }, [ hasNext, data, currentIndex ] );
+
+ const handlePrevious = useCallback( () => {
+ if ( hasPrevious && data && currentIndex >= 0 ) {
+ const prevItem = data[ currentIndex - 1 ];
+ if ( prevItem ) {
+ setCurrentResponse( prevItem );
+ }
+ }
+ }, [ hasPrevious, data, currentIndex ] );
+
+ const handleActionComplete = useCallback( () => {
+ closeModal?.();
+ }, [ closeModal ] );
+
+ 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 ]
+ );
+
+ 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 ) {
+ setSidePanelItem( nextItem );
+ onChangeSelection( [ getItemId( nextItem ) ] );
+ }
+ }
+ }, [ hasNext, data, currentIndex, setSidePanelItem, onChangeSelection ] );
+
+ const handlePrevious = useCallback( () => {
+ if ( hasPrevious && data && currentIndex >= 0 ) {
+ const prevItem = data[ currentIndex - 1 ];
+ if ( prevItem ) {
+ setSidePanelItem( prevItem );
+ onChangeSelection( [ getItemId( prevItem ) ] );
+ }
+ }
+ }, [ hasPrevious, data, currentIndex, setSidePanelItem, onChangeSelection ] );
+
+ return {
+ currentIndex,
+ hasNext,
+ hasPrevious,
+ handleNext,
+ handlePrevious,
+ };
+};
+
const SingleResponse = ( {
sidePanelItem,
setSidePanelItem,
@@ -422,61 +562,59 @@ const SingleResponse = ( {
[ onChangeSelection, selection ]
);
- // 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;
-
- const handleNext = useCallback( () => {
- if ( hasNext && data && currentIndex >= 0 ) {
- const nextItem = data[ currentIndex + 1 ];
- if ( nextItem ) {
- setSidePanelItem( nextItem );
- onChangeSelection( [ getItemId( nextItem ) ] );
- }
- }
- }, [ hasNext, data, currentIndex, setSidePanelItem, onChangeSelection ] );
-
- const handlePrevious = useCallback( () => {
- if ( hasPrevious && data && currentIndex >= 0 ) {
- const prevItem = data[ currentIndex - 1 ];
- if ( prevItem ) {
- setSidePanelItem( prevItem );
- onChangeSelection( [ getItemId( prevItem ) ] );
- }
- }
- }, [ hasPrevious, data, currentIndex, setSidePanelItem, onChangeSelection ] );
+ // 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..c9438bb90ffd1 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 (
@@ -578,6 +368,10 @@ const InboxResponse = ( {
} );
}, [ response, editEntityRecord, hasMarkedSelfAsRead ] );
+ const onMarkAsRead = useCallback( responseId => {
+ setHasMarkedSelfAsRead( responseId );
+ }, [] );
+
const handelImageLoaded = useCallback( () => {
return setIsImageLoading( false );
}, [ setIsImageLoading ] );
@@ -600,10 +394,26 @@ 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..4a8e477707194 100644
--- a/projects/packages/forms/src/dashboard/inbox/style.scss
+++ b/projects/packages/forms/src/dashboard/inbox/style.scss
@@ -414,6 +414,14 @@
}
}
+.jp-forms__inbox__response-mobile {
+
+ .jp-forms__inbox__response-mobile__header-heading {
+ font-size: 1.2rem;
+ font-weight: 600;
+ }
+}
+
/*
* We need to make the available canvas 100% tall. Without this,
@@ -528,3 +536,13 @@ body.jetpack_page_jetpack-forms-admin {
margin-left: -12px;
vertical-align: middle;
}
+
+.jp-forms-response-actions-mobile {
+ display: flex;
+ justify-content: space-between;
+ padding: 8px 0;
+ position: fixed;
+ bottom: 12px;
+ margin: 0;
+ gap: 12px;
+}
diff --git a/projects/packages/forms/src/types/index.ts b/projects/packages/forms/src/types/index.ts
index 54b18f7e3da55..cd958a34071c1 100644
--- a/projects/packages/forms/src/types/index.ts
+++ b/projects/packages/forms/src/types/index.ts
@@ -100,6 +100,8 @@ export interface FormResponse {
is_unread: boolean;
/** The fields of the response. */
fields: Record< string, unknown >;
+ /** Whether the response has been read. */
+ is_unread: boolean;
}
/**