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; } /**