diff --git a/projects/packages/forms/changelog/add-forms-response-view-actions b/projects/packages/forms/changelog/add-forms-response-view-actions new file mode 100644 index 0000000000000..2089c1a1ab475 --- /dev/null +++ b/projects/packages/forms/changelog/add-forms-response-view-actions @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Forms: add actions on dashboard inbox's single response view diff --git a/projects/packages/forms/src/dashboard/components/gravatar/style.scss b/projects/packages/forms/src/dashboard/components/gravatar/style.scss index 5a157b912b68d..082ae00c4fbd1 100644 --- a/projects/packages/forms/src/dashboard/components/gravatar/style.scss +++ b/projects/packages/forms/src/dashboard/components/gravatar/style.scss @@ -1,6 +1,7 @@ .jp-forms__gravatar { background-color: var(--jp-gray-5); border-radius: 50%; + flex-shrink: 0; // Prevent shrinking when Gravatar is inside flex containers height: 48px; overflow: hidden; width: 48px; diff --git a/projects/packages/forms/src/dashboard/inbox/dataviews/index.js b/projects/packages/forms/src/dashboard/inbox/dataviews/index.js index 1cd9d5d5c3c58..67d63a8fd9c24 100644 --- a/projects/packages/forms/src/dashboard/inbox/dataviews/index.js +++ b/projects/packages/forms/src/dashboard/inbox/dataviews/index.js @@ -37,7 +37,7 @@ import { useView, defaultLayouts } from './views'; const EMPTY_ARRAY = []; const MOBILE_BREAKPOINT = 780; -const getItemId = item => item.id.toString(); +const getItemId = item => item?.id?.toString() ?? ''; const formatFieldName = fieldName => { const match = fieldName.match( /^(\d+_)?(.*)/i ); @@ -207,16 +207,33 @@ export default function InboxView() { // set the sidePanelItem when we have data and selection. // We don't need to do this in `mobile`, because we don't render the side panel. if ( ! isMobile && !! data && !! selection.length ) { - const firstValidSelection = selection.find( id => - data.some( record => getItemId( record ) === id ) - ); - const recordToShow = data?.find( record => getItemId( record ) === firstValidSelection ); + // Find the last (most recently selected) valid selection instead of the first + const lastValidSelection = selection + .slice() + .reverse() + .find( id => data.some( record => getItemId( record ) === id ) ); + const recordToShow = data?.find( record => getItemId( record ) === lastValidSelection ); if ( ! sidePanelItem && recordToShow ) { setSidePanelItem( recordToShow ); } else if ( !! sidePanelItem && ! recordToShow ) { // This case handles the case where we were having a side panel item // visible but the data have changed and the item is not there anymore. setSidePanelItem(); + } else if ( + !! sidePanelItem && + !! recordToShow && + getItemId( sidePanelItem ) === getItemId( recordToShow ) && + sidePanelItem !== recordToShow + ) { + // Update side panel item if the data has been refreshed for the SAME item (e.g., after an action) + // This ensures the side panel shows the latest version of the same entity + setSidePanelItem( recordToShow ); + } else if ( + !! recordToShow && + ( ! sidePanelItem || getItemId( sidePanelItem ) !== getItemId( recordToShow ) ) + ) { + // Set side panel item when selecting a different item + setSidePanelItem( recordToShow ); } } const paginationInfo = useMemo( @@ -337,19 +354,30 @@ export default function InboxView() { setSidePanelItem={ setSidePanelItem } isLoadingData={ isLoadingData } isMobile={ isMobile } + data={ data } + onChangeSelection={ onChangeSelection } + selection={ selection } /> ); } -const SingleResponse = ( { sidePanelItem, setSidePanelItem, isLoadingData, isMobile } ) => { +const SingleResponse = ( { + sidePanelItem, + setSidePanelItem, + isLoadingData, + isMobile, + data, + onChangeSelection, + selection, +} ) => { const [ isChildModalOpen, setIsChildModalOpen ] = useState( false ); const onRequestClose = useCallback( () => { if ( ! isChildModalOpen ) { - setSidePanelItem(); + onChangeSelection( [] ); } - }, [ setSidePanelItem, isChildModalOpen ] ); + }, [ onChangeSelection, isChildModalOpen ] ); const handleModalStateChange = useCallback( isOpen => { @@ -358,6 +386,45 @@ const SingleResponse = ( { sidePanelItem, setSidePanelItem, isLoadingData, isMob [ setIsChildModalOpen ] ); + 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 ); + } + }, + [ 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 ] ); + if ( ! sidePanelItem ) { return null; } @@ -365,7 +432,14 @@ const SingleResponse = ( { sidePanelItem, setSidePanelItem, isLoadingData, isMob ); if ( ! isMobile ) { @@ -373,7 +447,7 @@ const SingleResponse = ( { sidePanelItem, setSidePanelItem, isLoadingData, isMob } return ( diff --git a/projects/packages/forms/src/dashboard/inbox/response.js b/projects/packages/forms/src/dashboard/inbox/response.js index 650f99c7360ff..83cf0b9e053da 100644 --- a/projects/packages/forms/src/dashboard/inbox/response.js +++ b/projects/packages/forms/src/dashboard/inbox/response.js @@ -13,11 +13,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 } 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 { __, sprintf } from '@wordpress/i18n'; -import { download } from '@wordpress/icons'; +import { download, close, chevronLeft, chevronRight } from '@wordpress/icons'; import clsx from 'clsx'; /** * Internal dependencies @@ -25,6 +26,13 @@ import clsx from 'clsx'; import CopyClipboardButton from '../components/copy-clipboard-button'; import Gravatar from '../components/gravatar'; import { useMarkAsSpam } from '../hooks/use-mark-as-spam'; +import { + markAsSpamAction, + markAsNotSpamAction, + moveToTrashAction, + restoreAction, + deleteAction, +} from './dataviews/actions'; import { getPath } from './utils'; const getDisplayName = response => { @@ -171,15 +179,33 @@ const FileField = ( { file, onClick } ) => { ); }; -const InboxResponse = ( { response, loading, onModalStateChange } ) => { +const InboxResponse = ( { + response, + loading, + onModalStateChange, + onClose, + onNext, + onPrevious, + hasNext, + hasPrevious, + onActionComplete, + isMobile, +} ) => { 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 ); // 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 ); + const registry = useRegistry(); + const ref = useRef( undefined ); const openFilePreview = useCallback( @@ -208,6 +234,164 @@ const InboxResponse = ( { response, loading, onModalStateChange } ) => { } }, [ 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( true ); + 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 renderActionButtons = () => { + switch ( response.status ) { + case 'spam': + return ( + <> + + + + ); + + case 'trash': + return ( + <> + + + + ); + + default: // 'publish' (inbox) or any other status + return ( + <> + + + + ); + } + }; + + const renderNavigationButtons = () => { + return ( + <> + { onPrevious && ( + + ) } + { onNext && ( + + ) } + { ! isMobile && onClose && ( + + ) } + + ); + }; + const renderFieldValue = value => { if ( isImageSelectField( value ) ) { return ( @@ -323,6 +507,10 @@ const InboxResponse = ( { response, loading, onModalStateChange } ) => { return ( <> + + { renderActionButtons() } + { renderNavigationButtons() } +
diff --git a/projects/packages/forms/src/dashboard/inbox/style.scss b/projects/packages/forms/src/dashboard/inbox/style.scss index 774fead7990cb..a00ac05c2c4fe 100644 --- a/projects/packages/forms/src/dashboard/inbox/style.scss +++ b/projects/packages/forms/src/dashboard/inbox/style.scss @@ -89,6 +89,16 @@ min-height: 48px; // Same as avatar to middle-align single lines } +.jp-forms__inbox-response-actions { + border-bottom: 1px solid var(--jp-forms-border-color); + padding: 8px 0; + width: 100%; + + @media (min-width: 782px) { + padding: 8px 20px; + } +} + .jp-forms__inbox-response-name { font-size: 24px; font-weight: 700; @@ -384,11 +394,14 @@ min-width: 300px !important; overflow: auto; flex: 1 1 40%; - + position: sticky; + top: var(--wp-admin--admin-bar--height, 32px); + height: calc(100vh - var(--wp-admin--admin-bar--height, 32px) - 97px); // Same as the margin-bottom on the .jp-forms__inbox__dataviews margin-bottom: 97px; @media (min-width: 782px) { + height: calc(100vh - var(--wp-admin--admin-bar--height, 32px) - 57px); margin-bottom: 57px; } @@ -462,10 +475,6 @@ body.jetpack_page_jetpack-forms-admin { // Styling override when loaded in modal on mobile .components-modal__content { - .jp-forms__inbox-response-avatar { - left: 0; - } - .jp-forms__inbox-response-title, .jp-forms__inbox-response-subtitle, .jp-forms__inbox-response-meta,