Skip to content

Conversation

@enejb
Copy link
Member

@enejb enejb commented Dec 12, 2025

This PR introduces a new toolbar that show up on the form block that lets us convert form blocks into synced form blocks.

See

Screen.Recording.2025-12-12.at.11.47.04.AM.mov

Proposed changes:

  • Add support for synced forms via a new ref attribute that references a jetpack_form custom post type
  • Introduce useSyncedForm hook to load and parse synced contact forms from the custom post type
  • Add ConvertFormToolbar component to enable converting between inline and synced forms directly from the block toolbar
  • Update contact form save logic to avoid saving innerBlocks for synced forms (content is managed centrally)
  • Add form sync manager utility with functions for serializing blocks and creating synced forms via the Jetpack API
  • Implement circular reference prevention and validation to only render published or draft forms
  • Improve TypeScript types for block parsing with proper type inference from WordPress parse function

Other information:

  • Have you written new tests for your changes, if applicable?
  • Have you checked the E2E test CI results, and verified that your changes do not break them?
  • Have you tested your changes on WordPress.com, if applicable (if so, you'll see a generated comment below with a script to run)?

Jetpack product discussion

Does this pull request change what data or activity we track or use?

No

Testing instructions:

Prerequisites:

  • Set up a WordPress site with Jetpack Forms installed
  • Ensure the central-form-management feature flag is enabled
add_filter( 'jetpack_block_editor_feature_flags', 'eb_register_feature' );

function eb_register_feature( $flags ) {
    $flags['central-form-management'] = true;
    return $flags;
}

Test converting inline form to synced form:

  1. Create a new post or page
  2. Add a Jetpack Contact Form block
  3. Add some fields (name, email, message, etc.)
  4. Configure form settings (notification email, confirmation message, etc.)
  5. Look for the "Convert to Synced Form" button in the block toolbar
  6. Click the button and observe:
    • A success notice should appear
    • The form should maintain all its fields and settings
    • The form should now have a ref attribute pointing to a jetpack_form post

Test editing synced form:

  1. With a synced form selected, click "Edit Form" in the block toolbar
  2. Verify it navigates to the form editor for the jetpack_form post
  3. Make changes to the form fields or settings
  4. Return to the original post and verify changes are reflected

Test rendering synced forms:

  1. Create multiple posts/pages
  2. Insert the same synced form (by ID) into multiple locations
  3. Verify the form renders correctly in all locations
  4. Edit the synced form from one location
  5. Verify the changes appear in all instances of the form

Test circular reference prevention:

  1. Attempt to create nested synced forms (if possible)
  2. Verify the system prevents circular references

Test TypeScript compilation:

  1. Run pnpm run lint:ts in the forms package
  2. Verify no TypeScript errors related to syncedInnerBlocks or ParsedBlock types"--base trunk

@enejb enejb requested review from a team and Copilot December 12, 2025 19:49
@github-actions
Copy link
Contributor

github-actions bot commented Dec 12, 2025

Are you an Automattician? Please test your changes on all WordPress.com environments to help mitigate accidental explosions.

  • To test on WoA, go to the Plugins menu on a WoA dev site. Click on the "Upload" button and follow the upgrade flow to be able to upload, install, and activate the Jetpack Beta plugin. Once the plugin is active, go to Jetpack > Jetpack Beta, select your plugin (Jetpack or WordPress.com Site Helper), and enable the update/form-block-saves-custom-post-type branch.
  • To test on Simple, run the following command on your sandbox:
bin/jetpack-downloader test jetpack update/form-block-saves-custom-post-type
bin/jetpack-downloader test jetpack-mu-wpcom-plugin update/form-block-saves-custom-post-type

Interested in more tips and information?

  • In your local development environment, use the jetpack rsync command to sync your changes to a WoA dev blog.
  • Read more about our development workflow here: PCYsg-eg0-p2
  • Figure out when your changes will be shipped to customers here: PCYsg-eg5-p2

@github-actions
Copy link
Contributor

github-actions bot commented Dec 12, 2025

Thank you for your PR!

When contributing to Jetpack, we have a few suggestions that can help us test and review your patch:

  • ✅ Include a description of your PR changes.
  • 🔴 Add a "[Status]" label (In Progress, Needs Review, ...).
  • 🔴 Add a "[Type]" label (Bug, Enhancement, Janitorial, Task).
  • ✅ Add testing instructions.
  • ✅ Specify whether this PR includes any changes to data or privacy.
  • ✅ Add changelog entries to affected projects

This comment will be updated as you work on your PR and make changes. If you think that some of those checks are not needed for your PR, please explain why you think so. Thanks for cooperation 🤖


Follow this PR Review Process:

  1. Ensure all required checks appearing at the bottom of this PR are passing.
  2. Make sure to test your changes on all platforms that it applies to. You're responsible for the quality of the code you ship.
  3. You can use GitHub's Reviewers functionality to request a review.
  4. When it's reviewed and merged, you will be pinged in Slack to deploy the changes to WordPress.com simple once the build is done.

If you have questions about anything, reach out in #jetpack-developers for guidance!

@github-actions github-actions bot added the [Status] Needs Author Reply We need more details from you. This label will be auto-added until the PR meets all requirements. label Dec 12, 2025
@enejb enejb added [Status] Needs Review This PR is ready for review. DO NOT MERGE don't merge it! and removed [Status] Needs Author Reply We need more details from you. This label will be auto-added until the PR meets all requirements. labels Dec 12, 2025
@github-actions github-actions bot added [Status] Needs Author Reply We need more details from you. This label will be auto-added until the PR meets all requirements. and removed [Status] Needs Review This PR is ready for review. labels Dec 12, 2025
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces synced form functionality to Jetpack Forms, allowing forms to be stored centrally in a jetpack_form custom post type and reused across multiple locations. Forms can be converted between inline and synced modes via a new toolbar interface, with content managed centrally for synced forms.

Key Changes:

  • Added ref attribute to contact form blocks to reference centrally managed forms stored in the jetpack_form custom post type
  • Implemented conversion toolbar enabling users to convert inline forms to synced forms and navigate to edit synced forms
  • Created PHP rendering logic with circular reference prevention and post status validation for synced forms

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 18 comments.

Show a summary per file
File Description
projects/packages/forms/src/blocks/contact-form/utils/form-sync-manager.ts New utility module providing functions to serialize form blocks and create synced forms via REST API
projects/packages/forms/src/blocks/contact-form/hooks/use-synced-form.ts New custom hook to load and parse synced forms from the jetpack_form post type with proper TypeScript type inference
projects/packages/forms/src/blocks/contact-form/components/convert-form-toolbar.tsx New toolbar component with buttons to convert forms to synced mode and edit existing synced forms
projects/packages/forms/src/blocks/contact-form/edit.tsx Updated editor component to integrate synced form loading, display loading states, and sync attributes/innerBlocks from referenced forms
projects/packages/forms/src/blocks/contact-form/index.js Modified save function to return null for synced forms (ref attribute) since content is stored centrally
projects/packages/forms/src/blocks/contact-form/class-contact-form-block.php Added render_synced_form method with circular reference prevention, post validation, and status checks
projects/packages/forms/src/blocks/contact-form/attributes.ts Added ref attribute definition to support referencing jetpack_form posts
projects/packages/forms/changelog/update-form-block-saves-custom-post-type Changelog entry documenting the addition of ConvertFormToolbar component
Comments suppressed due to low confidence (1)

projects/packages/forms/src/blocks/contact-form/class-contact-form-block.php:768

  • When a synced form has an invalid post status (not publish or draft), the function returns an empty string with no user feedback. Consider returning an error message to help users understand why the form isn't displaying, especially for forms that might have been accidentally trashed or set to pending.
		// Only render published and draft post statuses.
		// todo: add a "active" status so that we can disable forms without deleting them.
		if ( ! in_array( $synced_form->post_status, array( 'publish', 'draft' ), true ) ) {
			return '';
		}

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

}

// Only render published and draft post statuses.
// todo: add a "active" status so that we can disable forms without deleting them.
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This TODO comment should be tracked in an issue rather than left inline. Consider creating a GitHub issue to track the implementation of an 'active' status for forms and removing this comment.

Suggested change
// todo: add a "active" status so that we can disable forms without deleting them.

Copilot uses AI. Check for mistakes.
Comment on lines 380 to 383
// Clear syncing flag after a short delay
setTimeout( () => {
isSyncingRef.current = false;
}, 100 );
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using setTimeout with a hardcoded 100ms delay to clear the syncing flag is fragile and may not account for slow operations or race conditions. Consider using a ref callback or useLayoutEffect to ensure the flag is cleared synchronously after the DOM updates, or implement proper promise-based state management.

Copilot uses AI. Check for mistakes.
Comment on lines +7 to +13
import { serialize } from '@wordpress/blocks';

const FORM_BLOCK_NAME = 'jetpack/contact-form';

export interface BlockData {
attributes: Record< string, unknown >;
innerBlocks: unknown[];
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The innerBlocks property is typed as unknown[] which bypasses type safety. Consider using a more specific type like Block[] or the appropriate block type from WordPress to ensure type safety when working with block structures.

Suggested change
import { serialize } from '@wordpress/blocks';
const FORM_BLOCK_NAME = 'jetpack/contact-form';
export interface BlockData {
attributes: Record< string, unknown >;
innerBlocks: unknown[];
import { serialize, Block } from '@wordpress/blocks';
const FORM_BLOCK_NAME = 'jetpack/contact-form';
export interface BlockData {
attributes: Record< string, unknown >;
innerBlocks: Block[];

Copilot uses AI. Check for mistakes.
[ clientId ]
);

// // Load synced innerBlocks and attributes when a ref exists and the form is loaded
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment has double forward slashes at the beginning. Remove one forward slash to follow standard comment formatting.

Suggested change
// // Load synced innerBlocks and attributes when a ref exists and the form is loaded
// Load synced innerBlocks and attributes when a ref exists and the form is loaded

Copilot uses AI. Check for mistakes.
Comment on lines +24 to +26
* Serializes a block (with attributes and innerBlocks) to block markup string
*
* @param {object} blockData - Block data containing attributes and innerBlocks
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation states this serializes a block, but the function accepts BlockData (with attributes and innerBlocks) rather than a full block object. Consider clarifying the parameter documentation to say "block data" instead of "block" to match the function name and parameter type.

Suggested change
* Serializes a block (with attributes and innerBlocks) to block markup string
*
* @param {object} blockData - Block data containing attributes and innerBlocks
* Serializes block data (with attributes and innerBlocks) to block markup string
*
* @param {BlockData} blockData - Block data containing attributes and innerBlocks

Copilot uses AI. Check for mistakes.

try {
// Remove ref from attributes if it exists (shouldn't, but safety check)
const { ...cleanAttributes } = attributes;
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The destructuring syntax here doesn't actually remove the 'ref' property from attributes. The spread operator ...cleanAttributes creates a shallow copy of all properties in attributes. To properly exclude the 'ref' property, you should use: const { ref, ...cleanAttributes } = attributes;

Suggested change
const { ...cleanAttributes } = attributes;
const { ref, ...cleanAttributes } = attributes;

Copilot uses AI. Check for mistakes.
Comment on lines +746 to +782
// Circular reference prevention.
static $seen_refs = array();

if ( isset( $seen_refs[ $ref_id ] ) ) {
return sprintf(
'<div class="wp-block-jetpack-contact-form">%s</div>',
esc_html__( 'Circular reference detected in form.', 'jetpack-forms' )
);
}

// Load the jetpack-form post.
$synced_form = get_post( $ref_id );

// Validate post.
if ( ! $synced_form || 'jetpack_form' !== $synced_form->post_type ) {
return '';
}

// Only render published and draft post statuses.
// todo: add a "active" status so that we can disable forms without deleting them.
if ( ! in_array( $synced_form->post_status, array( 'publish', 'draft' ), true ) ) {
return '';
}

// Mark as seen for circular reference prevention.
$seen_refs[ $ref_id ] = true;

// Parse and render blocks from post_content.
$blocks = parse_blocks( $synced_form->post_content );
$output = '';

foreach ( $blocks as $block ) {
$output .= render_block( $block );
}

// Clean up.
unset( $seen_refs[ $ref_id ] );
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The static $seen_refs array for circular reference prevention will persist across multiple requests in persistent PHP environments (like PHP-FPM). If an error occurs between marking a ref as seen and unsetting it, subsequent requests could incorrectly detect circular references. Consider adding cleanup logic or using instance-level tracking instead of static variables.

Copilot uses AI. Check for mistakes.
interface ConvertFormToolbarProps {
clientId: string;
attributes: Record< string, unknown >;
setAttributes: ( attrs: Record< string, unknown > ) => void;
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The setAttributes parameter in the ConvertFormToolbarProps interface is defined but never used in the component. Consider removing it from both the interface and the component's parameter destructuring, or document why it's included if it's needed for future functionality.

Suggested change
setAttributes: ( attrs: Record< string, unknown > ) => void;

Copilot uses AI. Check for mistakes.
Comment on lines +732 to +757
if ( isset( $atts['ref'] ) && is_numeric( $atts['ref'] ) ) {
return self::render_synced_form( $atts['ref'] );
}

return Contact_Form::parse( $atts, do_blocks( $content ) );
}

/**
* Render a synced form by reference ID.
*
* @param int $ref_id The jetpack_form post ID.
* @return string Rendered form HTML.
*/
private static function render_synced_form( $ref_id ) {
// Circular reference prevention.
static $seen_refs = array();

if ( isset( $seen_refs[ $ref_id ] ) ) {
return sprintf(
'<div class="wp-block-jetpack-contact-form">%s</div>',
esc_html__( 'Circular reference detected in form.', 'jetpack-forms' )
);
}

// Load the jetpack-form post.
$synced_form = get_post( $ref_id );
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ref_id parameter is validated with is_numeric(), but this doesn't prevent negative numbers or ensure the value is a valid positive integer. Consider using absint() or additional validation to ensure only valid positive integers are processed as post IDs.

Copilot uses AI. Check for mistakes.
Comment on lines +764 to +766
// Only render published and draft post statuses.
// todo: add a "active" status so that we can disable forms without deleting them.
if ( ! in_array( $synced_form->post_status, array( 'publish', 'draft' ), true ) ) {
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The check allowing jetpack_form posts with post_status of draft to be rendered means draft forms can be displayed on public pages if a block references their ref ID. This bypasses the usual WordPress expectation that drafts are not publicly visible and could unintentionally expose in-progress or disabled forms and their configuration to unauthenticated visitors. Restrict rendering to publish status for front-end requests (and/or gate draft rendering behind appropriate capability checks or editor-only contexts) so that non-public form statuses cannot be surfaced to general users.

Suggested change
// Only render published and draft post statuses.
// todo: add a "active" status so that we can disable forms without deleting them.
if ( ! in_array( $synced_form->post_status, array( 'publish', 'draft' ), true ) ) {
// Only render published forms on the front end.
// Allow drafts only in admin/editor contexts or for users with edit capability.
if (
'publish' !== $synced_form->post_status &&
(
! is_admin() &&
! ( defined( 'REST_REQUEST' ) && REST_REQUEST ) &&
! ( defined( 'DOING_AJAX' ) && DOING_AJAX ) &&
! current_user_can( 'edit_post', $synced_form->ID )
)
) {

Copilot uses AI. Check for mistakes.
@jp-launch-control
Copy link

jp-launch-control bot commented Dec 12, 2025

Code Coverage Summary

Coverage changed in 7 files. Only the first 5 are listed here.

File Coverage Δ% Δ Uncovered
projects/packages/forms/src/blocks/contact-form/variation-picker.js 0/45 (0.00%) 0.00% 21 💔
projects/packages/forms/src/blocks/contact-form/class-contact-form-block.php 488/612 (79.74%) -2.69% 20 💔
projects/packages/forms/src/blocks/contact-form/edit.tsx 0/283 (0.00%) 0.00% 10 💔
projects/packages/forms/src/blocks/contact-form/index.js 0/7 (0.00%) 0.00% 2 ❤️‍🩹
projects/packages/forms/src/blocks/shared/util/constants.js 0/10 (0.00%) 0.00% 1 ❤️‍🩹

9 files are newly checked for coverage. Only the first 5 are listed here.

File Coverage
projects/packages/forms/src/blocks/contact-form/components/convert-form-toolbar.tsx 0/34 (0.00%) 💔
projects/packages/forms/src/blocks/contact-form/hooks/use-synced-form-auto-save.ts 0/8 (0.00%) 💔
projects/packages/forms/src/blocks/contact-form/hooks/use-synced-form-loader.ts 0/18 (0.00%) 💔
projects/packages/forms/src/blocks/contact-form/hooks/use-synced-form.ts 0/18 (0.00%) 💔
projects/packages/forms/src/blocks/contact-form/utils/form-sync-manager.ts 0/5 (0.00%) 💔

Full summary · PHP report · JS report

If appropriate, add one of these labels to override the failing coverage check: Covered by non-unit tests Use to ignore the Code coverage requirement check when E2Es or other non-unit tests cover the code Coverage tests to be added later Use to ignore the Code coverage requirement check when tests will be added in a follow-up PR I don't care about code coverage for this PR Use this label to ignore the check for insufficient code coveage.

Introduces a new 'ref' attribute to the contact form block, allowing forms to be rendered by referencing a jetpack_form post by ID. Implements circular reference prevention and ensures only published or draft forms are rendered.
Introduces a custom React hook to load and parse synced contact forms from the jetpack_form post type. The hook fetches the referenced form, parses its block content, and returns loading state, attributes, and inner blocks for use in the contact form block.
The save function now checks for the 'ref' attribute. If present, it returns null to avoid saving innerBlocks for synced forms, as their content is managed elsewhere. Inline forms continue to save the full block with innerBlocks.
Introduces utilities to serialize contact form blocks and create synced forms via the Jetpack API. Provides type definitions and functions for converting between inline and synced form modes.
Introduces the ConvertFormToolbar component to enable converting contact forms to synced forms and editing synced forms directly from the block toolbar. Updates the contact form edit logic to support loading and syncing form data when a ref is present, and conditionally displays the toolbar based on the central form management feature flag.
@enejb enejb force-pushed the update/form-block-saves-custom-post-type branch from cac1db9 to 782c3eb Compare December 12, 2025 20:19
@github-actions github-actions bot added the Docs label Dec 12, 2025
Replaced hardcoded 'jetpack_form' strings with the FORM_POST_TYPE constant across multiple files for consistency and maintainability. The constant is now defined in shared/util/constants.js and imported where needed.
Introduces an isJetpackFormEditor flag using the current post type and FORM_POST_TYPE constant. Updates the ConvertFormToolbar rendering to only show when not in the Jetpack form editor, improving context-aware UI behavior.
Refactors the contact form block to support inline editing of reusable (synced) forms by loading, parsing, and saving form content directly from/to the referenced form post. Introduces logic to fetch and apply reusable form attributes and inner blocks, and ensures changes are persisted back to the source form. Adds error handling for missing referenced forms and improves loading state handling. Also adds a new hook (use-auto-save-synced-form) for auto-saving synced form changes when the parent post is saved.
Introduces a new Form Document Settings React component and plugin, displaying form configuration panels in the Document Settings sidebar for jetpack_form post types. Updates the form editor to register this plugin, adds related documentation, and includes supporting styles. Also updates dependencies to include @wordpress/edit-post and @wordpress/plugins.
</PluginDocumentSettingPanel>
</>
);
}
Copy link
Contributor

@edanzer edanzer Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Settings on block vs post? this version of the form post type, we show the Form block. If we're going to show that anyways, is it worth the extra code and complexity here to move Form block settings from the block to the post type?

Maybe you're trying to prepare for hiding the form block itself?

no-form-block-settings

) }
</ToolbarGroup>
);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Edit form. It's a bit odd to have an 'Edit Form' button on the form block, when I just edit it directly on the screen. I understand why the button is there, but it may not be obvious to users. I'm also not sure what the better solution is.

edit-form

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Block] Contact Form Form block (also see Contact Form label) DO NOT MERGE don't merge it! Docs [Feature] Contact Form [Package] Forms [Status] Needs Author Reply We need more details from you. This label will be auto-added until the PR meets all requirements.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants