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,