/** * WordPress dependencies */ import apiFetch from '@wordpress/api-fetch'; import { Button, ExternalLink, Modal, Spinner, Tip } from '@wordpress/components'; import { store as coreStore } from '@wordpress/core-data'; import { useDispatch, useSelect } from '@wordpress/data'; import { useCallback, useEffect, useMemo, useState } from '@wordpress/element'; import { decodeEntities } from '@wordpress/html-entities'; import { __, _n } from '@wordpress/i18n'; import { useParams, useSearch, useNavigate } from '@wordpress/route'; import * as React from 'react'; /** * Internal dependencies */ import CopyClipboardButton from '../../../src/dashboard/components/copy-clipboard-button'; import PreviewFile from '../../../src/dashboard/components/inspector/preview-file'; import ResponseMeta from '../../../src/dashboard/components/inspector/response-meta'; import { isFileUploadField, isImageSelectField, isLikelyPhoneNumber, } from '../../../src/dashboard/components/inspector/utils'; import useInboxData from '../../../src/dashboard/hooks/use-inbox-data.ts'; import { ResponseActions } from './actions'; import { ResponseNavigation } from './navigation'; import type { DispatchActions, SelectActions } from '../../../src/dashboard/inbox/stage/types.tsx'; import type { FormResponse } from '../../../src/types/index.ts'; import '../../../src/dashboard/components/inspector/style.scss'; type DisplayField = { label: string; value: unknown; key: string; }; const getDisplayFields = ( fields: FormResponse[ 'fields' ] | undefined | null ): DisplayField[] => { if ( ! fields ) { return []; } // New collection format: [{ label, value, key, ... }] if ( Array.isArray( fields ) ) { return fields.map( ( field, index ) => ( { label: field.label || field.key || String( index ), value: field.value, key: field.key || field.id || `${ index }-${ field.label }`, } ) ); } // Legacy format: { [label]: value } return Object.entries( fields ).map( ( [ label, value ] ) => ( { label, value, key: label, } ) ); }; type UploadedFile = { url: string; name: string; is_image?: boolean; }; /** * Renders a list of uploaded files. * * @param props - Props used while rendering the list of uploaded files. * @param props.files - The list of uploaded files. * @param props.handleFilePreview - Callback fired when a file is clicked. * * @return - Element containing the list of uploaded files. */ function FieldFile( { files, handleFilePreview, }: { files: Array< UploadedFile >; handleFilePreview: ( file: UploadedFile ) => () => void; } ) { return ( ); } /** * Renders an email address. * * @param props - Props used while rendering the email address. * @param props.email - The email address to render. * * @return - Element containing the email address. */ function FieldEmail( { email }: { email: string } ) { return ( { email } ); } type ImageSelectChoice = { url: string; name: string; selected?: boolean; }; /** * Creates a handler for the enter key. * * @param handler - The handler to call when the enter key is pressed. * * @return - Function that handles the enter key press. */ function createEnterKeyHandler( handler: () => void ) { return function handleEnterKeyDown( event: React.KeyboardEvent< HTMLDivElement > ) { if ( event.key === 'Enter' ) { handler(); } }; } /** * Renders a list of image choices. * * @param props - Props used while rendering the list of image choices. * @param props.choices - The list of image choices. * @param props.handleFilePreview - Callback fired when a image choice is clicked. * * @return - Element containing the list of image choices. */ function FieldImageSelect( { choices, handleFilePreview, }: { choices: Array< ImageSelectChoice >; handleFilePreview: ( choice: ImageSelectChoice ) => () => void; } ) { return (
{ choices.map( ( choice, index ) => { const previewHandler = handleFilePreview( choice ); const keyDownHandler = createEnterKeyHandler( previewHandler ); return (
{
); } ) }
); } /** * Renders a single response. * * @param props - Props used while rendering a single response. * @param props.responseId - The ID of the response to render. * @param props.allResponseIds - The IDs of all responses. * @param props.onNavigate - Callback fired when the response is navigated. * @param props.onClose - Callback fired when the response is closed. * * @return - Element containing the single response. */ function SingleResponseView( { responseId, allResponseIds, onNavigate, onClose, }: { responseId: number; allResponseIds: number[]; onNavigate: ( id: number ) => void; onClose: () => void; } ) { const [ previewFile, setPreviewFile ] = useState< { url: string; name: string } | null >( null ); const [ isImageLoading, setIsImageLoading ] = useState( true ); const [ hasMarkedAsRead, setHasMarkedAsRead ] = useState< number | null >( null ); const { editEntityRecord } = useDispatch( coreStore ) as unknown as DispatchActions; const { response, isLoading } = useSelect( select => { if ( ! responseId ) { return { response: null, isLoading: false }; } return { response: ( select( coreStore ) as unknown as SelectActions ).getEntityRecord( 'postType', 'feedback', responseId ) as unknown as FormResponse | null, isLoading: ( select( coreStore ) as unknown as SelectActions ).isResolving( 'getEntityRecord', [ 'postType', 'feedback', responseId ] ), }; }, [ responseId ] ); const currentIndex = allResponseIds.indexOf( responseId ); const hasNext = currentIndex < allResponseIds.length - 1; const hasPrevious = currentIndex > 0; const handleNext = useCallback( () => { if ( hasNext ) { onNavigate( allResponseIds[ currentIndex + 1 ] ); } }, [ hasNext, allResponseIds, currentIndex, onNavigate ] ); const handlePrevious = useCallback( () => { if ( hasPrevious ) { onNavigate( allResponseIds[ currentIndex - 1 ] ); } }, [ hasPrevious, allResponseIds, currentIndex, onNavigate ] ); // Keyboard navigation useEffect( () => { const handleKeyDown = ( event: KeyboardEvent ) => { if ( event.key === 'ArrowUp' && hasPrevious ) { event.preventDefault(); handlePrevious(); } else if ( event.key === 'ArrowDown' && hasNext ) { event.preventDefault(); handleNext(); } else if ( event.key === 'Escape' ) { onClose(); } }; window.addEventListener( 'keydown', handleKeyDown ); return () => window.removeEventListener( 'keydown', handleKeyDown ); }, [ hasNext, hasPrevious, handleNext, handlePrevious, onClose ] ); // Mark as read when viewing useEffect( () => { if ( ! response || ! response.id || ! response.is_unread ) { return; } if ( hasMarkedAsRead === response.id ) { return; } setHasMarkedAsRead( response.id ); editEntityRecord( 'postType', 'feedback', response.id, { is_unread: false, } ); apiFetch( { path: `/wp/v2/feedback/${ response.id }/read`, method: 'POST', data: { is_unread: false }, } ).catch( () => { editEntityRecord( 'postType', 'feedback', response.id, { is_unread: true, } ); } ); }, [ response, editEntityRecord, hasMarkedAsRead ] ); const handleFilePreview = useCallback( ( file: { url: string; name: string } ) => () => { setIsImageLoading( true ); setPreviewFile( file ); }, [] ); const closePreviewModal = useCallback( () => { setPreviewFile( null ); setIsImageLoading( true ); }, [] ); const handleImageLoaded = useCallback( () => { setIsImageLoading( false ); }, [] ); const handleActionComplete = useCallback( ( updatedItem: FormResponse | null ) => { if ( ! updatedItem ) { if ( hasNext ) { handleNext(); } else if ( hasPrevious ) { handlePrevious(); } else { onClose(); } } }, [ hasNext, hasPrevious, handleNext, handlePrevious, onClose ] ); const renderFieldValue = ( value: unknown ) => { if ( value === null || value === undefined ) { return '-'; } if ( isImageSelectField( value ) ) { return ( ); } if ( isFileUploadField( value ) ) { return ( ); } const emailRegEx = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i; if ( typeof value === 'string' && emailRegEx.test( value ) ) { return ; } if ( isLikelyPhoneNumber( value ) ) { return { value as string }; } if ( Array.isArray( value ) ) { return value.join( ', ' ); } if ( typeof value === 'object' ) { return JSON.stringify( value ); } return String( value ); }; const displayFields = useMemo( () => getDisplayFields( response?.fields ), [ response?.fields ] ); if ( isLoading ) { return (
); } if ( ! response ) { return (

{ __( 'Response not found.', 'jetpack-forms' ) }

); } return ( <>
{ displayFields.length > 0 && (
{ displayFields.map( ( { label, value, key } ) => (
{ label.endsWith( '?' ) ? label : `${ label }:` }
{ renderFieldValue( value ) }
) ) }
) } { response.status === 'spam' && (
{ __( 'Spam responses are permanently deleted after 15 days.', 'jetpack-forms' ) }
) } { response.status === 'trash' && (
{ _n( 'Items in trash are permanently deleted after 30 days.', 'Items in trash are permanently deleted after 30 days.', 30, 'jetpack-forms' ) }
) }
{ previewFile && ( ) } ); } /** * Renders the response contents for inspector panel. * * @return - Element containing response contents. */ export default function Response() { const params = useParams( { from: '/responses/$view' } ); const searchParams = useSearch( { from: '/responses/$view' } ); const navigate = useNavigate(); const responseIds = searchParams?.responseIds || []; const statusView = params.view === 'spam' || params.view === 'trash' ? params.view : 'inbox'; const { records } = useInboxData( { status: statusView } ); const allRecordIds = records?.map( record => record.id ) ?? []; const handleClose = useCallback( () => { navigate( { search: { ...searchParams, responseIds: undefined, }, } ); }, [ navigate, searchParams ] ); const handleNavigate = useCallback( ( id: number ) => { navigate( { search: { ...searchParams, responseIds: [ String( id ) ], }, } ); }, [ navigate, searchParams ] ); if ( responseIds.length !== 1 ) { return null; } const selectedResponseId = Number( responseIds[ 0 ] ); return ( ); }