/* * External dependencies */ import { ThemeProvider } from '@automattic/jetpack-components'; import { useModuleStatus } from '@automattic/jetpack-shared-extension-utils'; import { URLInput, InspectorAdvancedControls, InspectorControls, useBlockProps, InnerBlocks, useInnerBlocksProps, store as blockEditorStore, BlockControls, BlockContextProvider, } from '@wordpress/block-editor'; import { createBlock } from '@wordpress/blocks'; import { ExternalLink, PanelBody, SelectControl, TextareaControl, TextControl, ToggleControl, } from '@wordpress/components'; import { useInstanceId } from '@wordpress/compose'; import { store as coreStore } from '@wordpress/core-data'; import { useSelect, useDispatch } from '@wordpress/data'; import { store as editorStore } from '@wordpress/editor'; import { useRef, useEffect, useCallback, lazy, Suspense } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import clsx from 'clsx'; /* * Internal dependencies */ import useFormsConfig from '../../hooks/use-forms-config'; import { store as singleStepStore } from '../../store/form-step-preview'; import { PREVIOUS_BUTTON_TEMPLATE, NEXT_BUTTON_TEMPLATE, NAVIGATION_TEMPLATE, } from '../form-step-navigation/edit'; import StepControls from '../shared/components/form-step-controls'; import InspectorHint from '../shared/components/inspector-hint'; import JetpackManageResponsesSettings from '../shared/components/jetpack-manage-responses-settings'; import { useFindBlockRecursively } from '../shared/hooks/use-find-block-recursively'; import useFormSteps from '../shared/hooks/use-form-steps'; import { SyncedAttributeProvider } from '../shared/hooks/use-synced-attributes'; import { CORE_BLOCKS } from '../shared/util/constants'; import { childBlocks } from './child-blocks'; import { ContactFormPlaceholder } from './components/jetpack-contact-form-placeholder'; import ContactFormSkeletonLoader from './components/jetpack-contact-form-skeleton-loader'; import JetpackEmailConnectionSettings from './components/jetpack-email-connection-settings'; import useFormBlockDefaults from './shared/hooks/use-form-block-defaults'; import VariationPicker from './variation-picker'; import './util/form-styles.js'; const IntegrationControls = lazy( () => import( './components/jetpack-integration-controls' ) ); // Transforms const FormTransitionState = { TO_MULTISTEP: 'to-multistep', TO_FORM: 'to-form', IS_MULTISTEP: 'is-multistep', IS_FORM: 'is-form', }; const validFields = childBlocks.filter( childBlock => { const settings = childBlock.settings as typeof childBlock.settings & { parent?: string | string[]; }; return ( ! settings.parent || settings.parent === 'jetpack/contact-form' || ( Array.isArray( settings.parent ) && settings.parent.includes( 'jetpack/contact-form' ) ) ); } ); const ALLOWED_BLOCKS = [ ...validFields.map( block => `jetpack/${ block.name }` ) ]; // At the top level of a multistep form we allow navigation, progress indicator // and the step-container itself (users may add it manually before steps are // auto-structured). The core block list remains unchanged. const ALLOWED_MULTI_STEP_BLOCKS = [ 'jetpack/form-step-navigation', 'jetpack/form-progress-indicator', 'jetpack/form-step-container', 'jetpack/form-step-divider', ].concat( CORE_BLOCKS ); const REMOVE_FIELDS_FROM_FORM = [ 'jetpack/form-step-navigation', 'jetpack/form-progress-indicator', 'jetpack/form-step-container', ]; const ALLOWED_FORM_BLOCKS = ALLOWED_BLOCKS.concat( CORE_BLOCKS ).filter( block => ! REMOVE_FIELDS_FROM_FORM.includes( block ) ); const PRIORITIZED_INSERTER_BLOCKS = [ ...validFields.map( block => `jetpack/${ block.name }` ) ]; // Determine if a block has a required attribute. Exclude hidden fields. const isInputWithRequiredField = ( fullName?: string ): boolean => { if ( ! fullName || ! fullName.startsWith( 'jetpack/' ) ) return false; const baseName = fullName.slice( 'jetpack/'.length ); const field = childBlocks.find( block => block.name === baseName ); // @ts-expect-error: childBlocks are defined in JS without explicit types. // TS is inferring the type wrong. Fix is to update childBlocks to TS with types. const hasRequired = field && field?.settings?.attributes?.required !== undefined; const isHidden = field?.name === 'field-hidden'; return hasRequired && ! isHidden; }; function JetpackContactFormEdit( { name, attributes, setAttributes, clientId, className } ) { // Initialize default form block settings as needed. useFormBlockDefaults( { attributes, setAttributes } ); const { to, subject, customThankyou, customThankyouHeading, customThankyouMessage, customThankyouRedirect, formTitle, variationName, emailNotifications, disableGoBack, } = attributes; const formsConfig = useFormsConfig(); const showFormIntegrations = Boolean( formsConfig?.isIntegrationsEnabled ); const instanceId = useInstanceId( JetpackContactFormEdit ); const steps = useFormSteps( clientId ); // Get current step info for context const currentStepInfo = useSelect( select => select( singleStepStore ).getCurrentStepInfo( clientId, steps ), [ clientId, steps ] ); const submitButton = useFindBlockRecursively( clientId, block => block.name === 'jetpack/button' ); const { postTitle, hasAnyInnerBlocks, postAuthorEmail, selectedBlockClientId, onlySubmitBlock } = useSelect( select => { const { getBlocks, getBlock, getSelectedBlockClientId, getBlockParentsByBlockName } = select( blockEditorStore ); const { getEditedPostAttribute } = select( editorStore ); const selectedBlockId = getSelectedBlockClientId(); const selectedBlock = getBlock( selectedBlockId ); let selectedStepBlockId = selectedBlockId; if ( selectedBlock && selectedBlock.name !== 'jetpack/form-step' ) { selectedStepBlockId = getBlockParentsByBlockName( selectedBlockId, 'jetpack/form-step' )[ 0 ]; } const { getUser } = select( coreStore ); const innerBlocksData = getBlocks( clientId ); const title = getEditedPostAttribute( 'title' ); const authorId = getEditedPostAttribute( 'author' ); const authorEmail = authorId && getUser( authorId )?.email; return { postTitle: title, hasAnyInnerBlocks: innerBlocksData.length > 0, postAuthorEmail: authorEmail, selectedBlockClientId: selectedStepBlockId, onlySubmitBlock: innerBlocksData.length === 1 && innerBlocksData[ 0 ].name === 'jetpack/button', }; }, [ clientId ] ); useEffect( () => { if ( submitButton && ! submitButton.attributes.lock ) { const lock = { move: false, remove: true }; submitButton.attributes.lock = lock; } }, [ submitButton ] ); const { isSingleStep, isFirstStep, isLastStep, currentStepClientId } = useSelect( select => { const { getCurrentStepInfo, isSingleStepMode } = select( singleStepStore ); const info = getCurrentStepInfo( clientId, steps ); return { isSingleStep: isSingleStepMode( clientId ), isFirstStep: info ? info.isFirstStep : false, isLastStep: info ? info.isLastStep : false, currentStepClientId: info ? info.clientId : null, }; }, [ clientId, steps ] ); const wrapperRef = useRef(); const innerRef = useRef(); const blockProps = useBlockProps( { ref: wrapperRef } ); const formClassnames = clsx( className, 'jetpack-contact-form', isFirstStep && 'is-first-step', isLastStep && 'is-last-step', variationName === 'multistep' && isSingleStep && 'is-previewing-step' ); const innerBlocksProps = useInnerBlocksProps( { ref: innerRef, className: formClassnames, style: window.jetpackForms.generateStyleVariables( innerRef.current ), }, { allowedBlocks: variationName === 'multistep' ? ALLOWED_MULTI_STEP_BLOCKS : ALLOWED_FORM_BLOCKS, prioritizedInserterBlocks: PRIORITIZED_INSERTER_BLOCKS, templateInsertUpdatesSelection: false, } ); const { isLoadingModules, isChangingStatus, isModuleActive, changeStatus } = useModuleStatus( 'contact-form' ); const { replaceInnerBlocks, __unstableMarkNextChangeAsNotPersistent, updateBlockAttributes } = useDispatch( blockEditorStore ); const currentInnerBlocks = useSelect( select => select( blockEditorStore ).getBlocks( clientId ), [ clientId ] ); // Track previous block count to detect insertions const previousBlockCountRef = useRef( currentInnerBlocks.length ); // Helper function to identify input field blocks const getInputFieldBlocks = useCallback( blocks => { const inputFields = []; const findInputFields = blockList => { blockList.forEach( block => { if ( isInputWithRequiredField( block.name ) ) { inputFields.push( block ); } // Recursively check inner blocks (for multistep forms) if ( block.innerBlocks && block.innerBlocks.length > 0 ) { findInputFields( block.innerBlocks ); } } ); }; findInputFields( blocks ); return inputFields; }, [] ); // Effect to handle block insertion and reordering useEffect( () => { const currentBlockCount = currentInnerBlocks.length; const previousBlockCount = previousBlockCountRef.current; // Detect if a block was just inserted const blockWasInserted = currentBlockCount > previousBlockCount; if ( blockWasInserted && currentInnerBlocks.length > 1 ) { // Find the submit button const submitButtonIndex = currentInnerBlocks.findIndex( block => block.name === 'jetpack/button' && ( block.attributes?.customVariant === 'submit' || block.attributes?.element === 'button' ) ); // If there's a submit button and it's not the last block, reorder if ( submitButtonIndex !== -1 && submitButtonIndex === currentInnerBlocks.length - 2 ) { // Move the submit button to the end const reorderedBlocks = [ ...currentInnerBlocks ]; const [ submitButtonBlock ] = reorderedBlocks.splice( submitButtonIndex, 1 ); reorderedBlocks.push( submitButtonBlock ); // Update the blocks without creating an undo step __unstableMarkNextChangeAsNotPersistent(); replaceInnerBlocks( clientId, reorderedBlocks, false ); } } // Update the previous block count previousBlockCountRef.current = currentBlockCount; }, [ currentInnerBlocks, clientId, replaceInnerBlocks, __unstableMarkNextChangeAsNotPersistent, ] ); // Effect to automatically make single input fields required useEffect( () => { const inputFields = getInputFieldBlocks( currentInnerBlocks ); // Only proceed if there's exactly one input field if ( inputFields.length === 1 ) { const singleField = inputFields[ 0 ]; // Check if the field is not already required if ( ! singleField.attributes?.required ) { // Update the field to be required updateBlockAttributes( singleField.clientId, { required: true } ); } } }, [ currentInnerBlocks, getInputFieldBlocks, updateBlockAttributes ] ); // Deep-scan helper – user might drop a Step block inside nested structures. const containsMultistepBlock = useCallback( function hasMultistep( blocks ) { return blocks.some( b => b.name === 'jetpack/form-step' || b.name === 'jetpack/form-step-container' || b.name === 'jetpack/form-step-divider' || ( b.innerBlocks?.length && hasMultistep( b.innerBlocks ) ) ); }, [] ); // Detect a conversion to a multistep form and structure inner blocks only once. const formTransitionStateRef = useRef< string | null >( null ); useEffect( () => { const hasMultistepBlock = containsMultistepBlock( currentInnerBlocks ); // Transition to single-step form state if no multistep blocks are present // and the variation is not set to 'multistep'. if ( ! hasMultistepBlock && variationName !== 'multistep' ) { formTransitionStateRef.current = FormTransitionState.IS_FORM; return; } // Transition to multistep form state if multistep blocks are present // and the variation is set to 'multistep'. if ( variationName === 'multistep' && hasMultistepBlock ) { formTransitionStateRef.current = FormTransitionState.IS_MULTISTEP; return; } // Transition from multistep form state to single-step form state. if ( formTransitionStateRef.current === FormTransitionState.IS_MULTISTEP ) { formTransitionStateRef.current = FormTransitionState.TO_FORM; return; } // Transition from single-step form state to multistep form state. formTransitionStateRef.current = FormTransitionState.TO_MULTISTEP; // If the form is not multistep, we don't need to do anything. }, [ variationName, currentInnerBlocks, containsMultistepBlock ] ); useEffect( () => { // Early exit if we are still on the multistep variation or if there are // no multistep-specific blocks to clean up. if ( formTransitionStateRef.current !== FormTransitionState.TO_MULTISTEP ) { return; } /* * Only skip the expensive restructuring logic when the form is **already in a * fully-structured multistep shape**: * – exactly one `jetpack/step-container` anywhere in the block tree, and * – there are **no** `form-step` blocks that live outside that container. * In all other cases we still need to normalise the tree (e.g. when the user * inserts a Step Container while other fields remain outside of it). */ const countBlocks = ( blocks, predicate ) => blocks.reduce( ( acc, b ) => acc + ( predicate( b ) ? 1 : 0 ) + countBlocks( b.innerBlocks || [], predicate ), 0 ); const stepContainerCount = countBlocks( currentInnerBlocks, b => b.name === 'jetpack/form-step-container' ); // Helper: detect any form-step that is NOT inside a step-container. const hasStrayFormStep = ( blocks, insideContainer = false ) => { for ( const b of blocks ) { const newInside = insideContainer || b.name === 'jetpack/form-step-container'; if ( b.name === 'jetpack/form-step' && ! newInside ) { return true; } if ( b.innerBlocks?.length && hasStrayFormStep( b.innerBlocks, newInside ) ) { return true; } } return false; }; const formIsFullyStructured = stepContainerCount === 1 && ! hasStrayFormStep( currentInnerBlocks ); if ( formIsFullyStructured ) { return; } // Helper functions const findButtonBlock = () => { const buttonIndex = currentInnerBlocks.findIndex( block => block.name === 'jetpack/button' ); return buttonIndex !== -1 ? { block: currentInnerBlocks[ buttonIndex ], index: buttonIndex, } : null; }; const prepareSubmitButton = button => { if ( ! button ) return null; const preparedButton = button; preparedButton.attributes.uniqueId = 'submit-step'; preparedButton.attributes.customVariant = 'submit'; preparedButton.attributes.metaName = __( 'Submit button', 'jetpack-forms' ); return preparedButton; }; const createStepNavigation = button => { // Find existing navigation block or create new one const existingNavigation = currentInnerBlocks.find( block => block.name === 'jetpack/form-step-navigation' ); if ( existingNavigation && button ) { // Add button to existing navigation return createBlock( 'jetpack/form-step-navigation', existingNavigation.attributes, [ ...( existingNavigation.innerBlocks || [] ), button, ] ); } else if ( existingNavigation ) { return existingNavigation; } // Create new navigation with or without button return createBlock( 'jetpack/form-step-navigation', { layout: { type: 'flex', justifyContent: 'right', }, }, button ? [ createBlock( PREVIOUS_BUTTON_TEMPLATE ), createBlock( NEXT_BUTTON_TEMPLATE ), button ] : NAVIGATION_TEMPLATE.map( createBlock ) ); }; const getProgressIndicator = () => { const existingIndicator = currentInnerBlocks.find( block => block.name === 'jetpack/form-progress-indicator' ); return existingIndicator || createBlock( 'jetpack/form-progress-indicator', {}, [] ); }; // 1. Extract button if it exists const buttonData = findButtonBlock(); const buttonBlock = buttonData ? buttonData.block : null; // 2. Get blocks excluding the button const blocksWithoutButton = buttonData ? currentInnerBlocks.filter( ( _, index ) => index !== buttonData.index ) : currentInnerBlocks; // 3. Prepare step container based on current blocks let stepBlocks = []; const containerIndex = blocksWithoutButton.findIndex( block => block.name === 'jetpack/form-step-container' ); if ( containerIndex !== -1 ) { // Case A: Step container was inserted. const beforeBlocks = blocksWithoutButton.slice( 0, containerIndex ); const afterBlocks = blocksWithoutButton.slice( containerIndex + 1 ); const existingStepContainer = blocksWithoutButton[ containerIndex ]; // Use existing steps if available, otherwise create new ones if ( existingStepContainer.innerBlocks && existingStepContainer.innerBlocks.length > 0 ) { stepBlocks = existingStepContainer.innerBlocks; } else { // Create steps from blocks before and after the container if ( beforeBlocks.length > 0 ) { stepBlocks.push( createBlock( 'jetpack/form-step', {}, beforeBlocks ) ); } if ( afterBlocks.length > 0 ) { stepBlocks.push( createBlock( 'jetpack/form-step', {}, afterBlocks ) ); } if ( stepBlocks.length === 0 ) { stepBlocks.push( createBlock( 'jetpack/form-step', {}, [] ) ); } } } else { // Case B: Has form-step block but no container const stepIndex = blocksWithoutButton.findIndex( block => block.name === 'jetpack/form-step' ); if ( stepIndex !== -1 ) { const beforeBlocks = blocksWithoutButton.slice( 0, stepIndex ); const afterBlocks = blocksWithoutButton.slice( stepIndex + 1 ); if ( beforeBlocks.length > 0 ) { stepBlocks.push( createBlock( 'jetpack/form-step', {}, beforeBlocks ) ); } stepBlocks.push( blocksWithoutButton[ stepIndex ] ); if ( afterBlocks.length > 0 ) { stepBlocks.push( createBlock( 'jetpack/form-step', {}, afterBlocks ) ); } } // Case C: No step blocks or containers — build steps based on divider markers. else if ( blocksWithoutButton.length > 0 ) { const hasDivider = blocksWithoutButton.some( b => b.name === 'jetpack/form-step-divider' ); if ( hasDivider ) { // Split by divider markers into groups const groups = []; let currentGroup = []; blocksWithoutButton.forEach( block => { if ( block.name === 'jetpack/form-step-divider' ) { // Commit current group (even empty to respect explicit divider) groups.push( currentGroup ); currentGroup = []; } else { currentGroup.push( block ); } } ); // Add the trailing group groups.push( currentGroup ); stepBlocks = groups.map( inner => createBlock( 'jetpack/form-step', {}, inner ) ); } else { // Fallback: one step per top-level block stepBlocks = blocksWithoutButton.map( block => createBlock( 'jetpack/form-step', {}, [ block ] ) ); } // Ensure at least one step exists if ( stepBlocks.length === 0 ) { stepBlocks = [ createBlock( 'jetpack/form-step', {}, [] ) ]; } } else { stepBlocks = [ createBlock( 'jetpack/form-step', {}, [] ) ]; } } // Create the step container with the step blocks const stepContainer = createBlock( 'jetpack/form-step-container', {}, stepBlocks ); // 4. Prepare all components for the final form const preparedButton = prepareSubmitButton( buttonBlock ); const stepNavigation = createStepNavigation( preparedButton ); const progressIndicator = getProgressIndicator(); // 5. Replace all inner blocks with our structured form (no extra undo level), // then flip the variation which *does* create the single desired snapshot. __unstableMarkNextChangeAsNotPersistent(); replaceInnerBlocks( clientId, [ progressIndicator, stepContainer, stepNavigation ], false ); // Ensure we are marked as multistep – this records the undo level. if ( variationName !== 'multistep' ) { setAttributes( { variationName: 'multistep' } ); } formTransitionStateRef.current = FormTransitionState.IS_MULTISTEP; }, [ variationName, currentInnerBlocks, clientId, replaceInnerBlocks, setAttributes, containsMultistepBlock, __unstableMarkNextChangeAsNotPersistent, ] ); /*─────────────────────────────────────────────────────────────────────────── * Flatten multistep structure → standard form *───────────────────────────────────────────────────────────────────────*/ useEffect( () => { // Early exit if we are still on the multistep variation or if there are // no multistep-specific blocks to clean up. if ( FormTransitionState.TO_FORM !== formTransitionStateRef.current ) { return; } // Will hold a reference to the submit button that should remain after cleanup. let finalSubmitButton = null; // Flatten helper – collects blocks that should remain in the standard form. const flattenBlocks = blocks => { let flat = []; blocks.forEach( block => { if ( block.name === 'jetpack/form-step-container' ) { // Extract inner content of each step. block.innerBlocks.forEach( step => { if ( step.name === 'jetpack/form-step' ) { flat = flat.concat( step.innerBlocks ); } } ); return; } if ( block.name === 'jetpack/form-step' ) { flat = flat.concat( block.innerBlocks ); return; } if ( block.name === 'jetpack/form-step-navigation' || block.name === 'jetpack/form-progress-indicator' || block.name === 'jetpack/form-step-divider' ) { // Capture submit button (if any) inside navigation but skip the wrapper. if ( ! finalSubmitButton ) { finalSubmitButton = block.innerBlocks?.find( inner => inner.name === 'jetpack/button' && inner.attributes?.customVariant === 'submit' ); } return; // Omit multistep-specific blocks. } // For any other block, keep as-is. flat.push( block ); } ); return flat; }; const flattenedInnerBlocks = flattenBlocks( currentInnerBlocks ); // Ensure we have a submit button at the end of the form. if ( ! finalSubmitButton ) { // Create a fresh submit button if none was found. finalSubmitButton = createBlock( 'jetpack/button', { element: 'button', text: __( 'Submit', 'jetpack-forms' ), } ); } const finalBlocks = [ ...flattenedInnerBlocks, finalSubmitButton ]; __unstableMarkNextChangeAsNotPersistent(); replaceInnerBlocks( clientId, finalBlocks, false ); // Reset the variation attribute if still set to something else. if ( variationName !== 'default-empty' ) { __unstableMarkNextChangeAsNotPersistent(); setAttributes( { variationName: 'default-empty' } ); } formTransitionStateRef.current = FormTransitionState.IS_FORM; }, [ variationName, currentInnerBlocks, containsMultistepBlock, clientId, replaceInnerBlocks, __unstableMarkNextChangeAsNotPersistent, setAttributes, ] ); const { setActiveStep } = useDispatch( singleStepStore ); // Find the selected block and its parent step block const selectedBlock = useFindBlockRecursively( selectedBlockClientId, block => block.clientId === selectedBlockClientId ); const stepBlock = useFindBlockRecursively( selectedBlock?.clientId || '', block => block.name === 'jetpack/form-step' ); useEffect( () => { if ( ! isSingleStep ) { return; } // If a block is selected, make sure it's in the current step if ( selectedBlockClientId && stepBlock && stepBlock.clientId !== currentStepClientId ) { setActiveStep( clientId, stepBlock.clientId ); } }, [ selectedBlockClientId, clientId, steps, setActiveStep, isSingleStep, currentStepClientId, stepBlock, ] ); let elt; if ( ! isModuleActive ) { if ( isLoadingModules ) { elt = ; } else { elt = ( ); } } else if ( ! hasAnyInnerBlocks ) { elt = ( ); } else if ( onlySubmitBlock ) { elt = ( <>
); } else { elt = ( <> { variationName === 'multistep' && } { __( 'Customize the view after form submission:', 'jetpack-forms' ) } setAttributes( { customThankyou: newMessage } ) } __nextHasNoMarginBottom={ true } __next40pxDefaultSize={ true } /> { 'redirect' !== customThankyou && ( <> setAttributes( { disableGoBack: newDisableGoBack } ) } __nextHasNoMarginBottom={ true } __next40pxDefaultSize={ true } /> setAttributes( { customThankyouHeading: newHeading } ) } __nextHasNoMarginBottom={ true } __next40pxDefaultSize={ true } /> ) } { 'message' === customThankyou && ( setAttributes( { customThankyouMessage: newMessage } ) } __nextHasNoMarginBottom={ true } /> ) } { 'redirect' === customThankyou && (
setAttributes( { customThankyouRedirect: newURL } ) } />
) }
{ showFormIntegrations && ( }> ) }
setAttributes( { formTitle: value } ) } help={ __( 'Add an accessible name to help people using assistive technology identify the form. Defaults to page or post title.', 'jetpack-forms' ) } __nextHasNoMarginBottom={ true } __next40pxDefaultSize={ true } /> { __( 'Read more.', 'jetpack-forms' ) }
); } return (
{ elt }
); } export default JetpackContactFormEdit;