Skip to content

Commit 638fba2

Browse files
enejbmatticbot
authored andcommitted
Forms: Add ref attribute support for synced/reusable forms (#46555)
* Add support for synced forms via ref attribute 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. * Add useSyncedForm hook for loading synced forms 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. * Update contact form save logic for synced forms 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. * Add form sync manager utility for contact forms 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. * Add ConvertFormToolbar for synced form management 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. * Create update-form-block-saves-custom-post-type * Add Form Editor Class and block locking * Refactor to use FORM_POST_TYPE constant 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. * Add isJetpackFormEditor check to contact form edit 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. * Add inline editing and syncing for reusable contact forms 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. * Create sync form on the variation picker * Refactor to simplify * Add Form Document Settings panel to form editor 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. * Forms: include form post id in feedback * Reverse changes from last commit * Store form_ref in feedback post_parent * Update the Convert Form button to just be the edit button. * Fix tests * Don't load the script in the site editor. * Hide the sidebar post noise * hide the post title in the editor * Simplify * Improve hiding the post title for the jetpack form editor. * Allow multiselect in form editor * Remove the box shadow from the form block. * Hide the breakcrumb * Update the categories in the inserter and remove the install plugin * Add a select a from dropdown to placeholder * Add command that lets us rename the form. * Fix phplinter * Fix js linter * improve the refId validation * Stash * fix double form POST TYPE * Remove cahngelog * Revert files to trunk * Changelog * change changelog * revert constants and variation picker * Remove the ConvertFormToolbar * Revert styles * Remove the unused form sync manager * Simplify * Always remove the lock when syncing the block * Add tests * Revert pnpm lock file * Add synced label * only allow valid refs to be submitted * Add tests for the PHP side * Minor changes Co-authored-by: Copilot <[email protected]> * clearTimeout when set Co-authored-by: Copilot <[email protected]> * FIX react-hooks/exhaustive-deps * curcular reference returns empty string Co-authored-by: Copilot <[email protected]> * Always clear ref id Co-authored-by: Copilot <[email protected]> * simplify the logic Co-authored-by: Copilot <[email protected]> * removed code that is not needed any more Co-authored-by: Copilot <[email protected]> * Make sure that output is always set * fix static * fix phan * Updates to the changelog * Update test names * Update the interface for JetpackForm * revert pnpm-lock.yaml * Remove isSyncedFormContext since it is not needed in this PR * Only render 'publish' forms * Fix test for draft form --------- Co-authored-by: Erick Danzer <[email protected]> Co-authored-by: Copilot <[email protected]> Committed via a GitHub action: https://github.com/Automattic/jetpack/actions/runs/20976310003 Upstream-Ref: Automattic/jetpack@61e48d9
1 parent 76ff293 commit 638fba2

File tree

17 files changed

+646
-161
lines changed

17 files changed

+646
-161
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
This is an alpha version! The changes listed here are not final.
88

99
### Enhancements
10+
- Forms: add ref attribute support for the form block.
1011
- Forms: make form webhooks generally available.
1112
- Instant Search: Show WooCommerce product filters in filter widget
1213

jetpack_vendor/automattic/jetpack-forms/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
This is an alpha version! The changes listed here are not final.
1111

1212
### Added
13+
- Add ref attribute support for the form block.
1314
- Forms: add centralized dashboard tabs.
1415
- Forms: make form webhooks generally available.
1516

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
<?php return array('dependencies' => array('jetpack-connection', 'jetpack-script-data', 'lodash', 'react', 'react-jsx-runtime', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-compose', 'wp-core-data', 'wp-data', 'wp-dom-ready', 'wp-editor', 'wp-element', 'wp-hooks', 'wp-i18n', 'wp-jp-i18n-loader', 'wp-notices', 'wp-plugins', 'wp-polyfill', 'wp-primitives', 'wp-url'), 'version' => '9838c0cd174b9c148855');
1+
<?php return array('dependencies' => array('jetpack-connection', 'jetpack-script-data', 'lodash', 'react', 'react-jsx-runtime', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-compose', 'wp-core-data', 'wp-data', 'wp-dom-ready', 'wp-editor', 'wp-element', 'wp-hooks', 'wp-i18n', 'wp-jp-i18n-loader', 'wp-notices', 'wp-plugins', 'wp-polyfill', 'wp-primitives', 'wp-url'), 'version' => 'bd1fbeabd5a2227cee5b');

jetpack_vendor/automattic/jetpack-forms/dist/blocks/editor.js

Lines changed: 12 additions & 12 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

jetpack_vendor/automattic/jetpack-forms/src/blocks/contact-form/attributes.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
import { __ } from '@wordpress/i18n';
55

66
export default {
7+
ref: {
8+
type: 'number' as const,
9+
},
710
subject: {
811
type: 'string',
912
default: window.jpFormsBlocks?.defaults?.subject || '',

jetpack_vendor/automattic/jetpack-forms/src/blocks/contact-form/class-contact-form-block.php

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -728,9 +728,65 @@ public static function gutenblock_render_form( $atts, $content ) {
728728

729729
self::load_view_scripts();
730730

731+
// Handle ref attribute - load form from jetpack-form post
732+
if ( isset( $atts['ref'] ) ) {
733+
$ref_id = absint( $atts['ref'] );
734+
if ( $ref_id > 0 ) {
735+
return self::render_synced_form( $ref_id );
736+
} else {
737+
return ''; // Invalid ref ID.
738+
}
739+
}
740+
731741
return Contact_Form::parse( $atts, do_blocks( $content ) );
732742
}
733743

744+
/**
745+
* Render a synced form by reference ID.
746+
*
747+
* @param int $ref_id The jetpack_form post ID.
748+
* @return string Rendered form HTML.
749+
*/
750+
private static function render_synced_form( $ref_id ) {
751+
// Circular reference prevention.
752+
static $seen_refs = array();
753+
754+
if ( isset( $seen_refs[ $ref_id ] ) ) {
755+
// Return empty string to match other error cases and unit test expectations.
756+
return '';
757+
}
758+
759+
// Load the jetpack-form post.
760+
$synced_form = get_post( $ref_id );
761+
762+
// Validate post.
763+
if ( ! $synced_form || 'jetpack_form' !== $synced_form->post_type ) {
764+
return '';
765+
}
766+
767+
// Only render published forms statuses.
768+
if ( ! in_array( $synced_form->post_status, array( 'publish' ), true ) ) {
769+
return '';
770+
}
771+
772+
// Mark as seen for circular reference prevention.
773+
$seen_refs[ $ref_id ] = true;
774+
Contact_Form::set_ref_id( $ref_id );
775+
$output = '';
776+
try {
777+
// Parse and render blocks from post_content.
778+
$blocks = parse_blocks( $synced_form->post_content );
779+
foreach ( $blocks as $block ) {
780+
$output .= render_block( $block );
781+
}
782+
} finally {
783+
// Clean up.
784+
unset( $seen_refs[ $ref_id ] );
785+
Contact_Form::clear_ref_id();
786+
}
787+
return $output;
788+
}
789+
734790
/**
735791
* Load editor styles for the block.
736792
* These are loaded via enqueue_block_assets to ensure proper loading in the editor iframe context.

jetpack_vendor/automattic/jetpack-forms/src/blocks/contact-form/edit.tsx

Lines changed: 68 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
import { createBlock } from '@wordpress/blocks';
1818
import {
1919
ExternalLink,
20+
Notice,
2021
PanelBody,
2122
TextareaControl,
2223
TextControl,
@@ -51,6 +52,9 @@ import { ContactFormPlaceholder } from './components/jetpack-contact-form-placeh
5152
import ContactFormSkeletonLoader from './components/jetpack-contact-form-skeleton-loader.js';
5253
import NotificationsSettings from './components/notifications-settings.js';
5354
import WebhooksSettings from './components/webhooks-settings.js';
55+
import { useSyncedFormAutoSave } from './hooks/use-synced-form-auto-save.ts';
56+
import { useSyncedFormLoader } from './hooks/use-synced-form-loader.ts';
57+
import { useSyncedForm } from './hooks/use-synced-form.ts';
5458
import useFormBlockDefaults from './shared/hooks/use-form-block-defaults.js';
5559
import VariationPicker from './variation-picker.js';
5660
import './util/form-styles.js';
@@ -133,6 +137,7 @@ type Webhook = {
133137
};
134138

135139
type JetpackContactFormAttributes = {
140+
ref?: number;
136141
to: string;
137142
subject: string;
138143
// Legacy support for the customThankyou attribute
@@ -169,6 +174,7 @@ function JetpackContactFormEdit( {
169174
useFormBlockDefaults( { attributes, setAttributes } );
170175

171176
const {
177+
ref,
172178
to,
173179
subject,
174180
customThankyou,
@@ -189,6 +195,14 @@ function JetpackContactFormEdit( {
189195
const showBlockIntegrations = useConfigValue( 'showBlockIntegrations' );
190196
const instanceId = useInstanceId( JetpackContactFormEdit );
191197

198+
// Load synced form data from the jetpack_form post type
199+
const {
200+
syncedForm,
201+
isLoading: isResolvingSyncedForm,
202+
syncedAttributes: syncedFormAttributes,
203+
syncedInnerBlocks: syncedFormBlocks,
204+
} = useSyncedForm( ref );
205+
192206
// Backward compatibility for the deprecated customThankyou attribute.
193207
// Older forms will have a customThankyou attribute set, but not a confirmationType attribute
194208
// and not a disableSummary attribute, so we need to set it here.
@@ -220,7 +234,6 @@ function JetpackContactFormEdit( {
220234
block => block.name === 'core/button' || block.name === 'jetpack/button',
221235
[]
222236
);
223-
224237
const submitButton = useFindBlockRecursively( clientId, findButtonsBlock );
225238

226239
const { postTitle, hasAnyInnerBlocks, postAuthorEmail, selectedBlockClientId, onlySubmitBlock } =
@@ -315,11 +328,38 @@ function JetpackContactFormEdit( {
315328
const { replaceInnerBlocks, __unstableMarkNextChangeAsNotPersistent, updateBlockAttributes } =
316329
useDispatch( blockEditorStore );
317330

331+
const { editEntityRecord } = useDispatch( coreStore );
332+
318333
const currentInnerBlocks = useSelect(
319334
select => select( blockEditorStore ).getBlocks( clientId ),
320335
[ clientId ]
321336
);
322337

338+
// Sync synced form content INTO the editor (one-time on ref change)
339+
const { isSyncingRef } = useSyncedFormLoader( {
340+
ref,
341+
syncedFormBlocks,
342+
syncedFormAttributes,
343+
clientId,
344+
setAttributes,
345+
replaceInnerBlocks,
346+
__unstableMarkNextChangeAsNotPersistent,
347+
} );
348+
349+
// Auto-save editor changes BACK to the synced form post
350+
useSyncedFormAutoSave( {
351+
ref,
352+
syncedForm,
353+
attributes,
354+
currentInnerBlocks,
355+
isSyncingRef,
356+
editEntityRecord,
357+
} );
358+
359+
// Note: We don't clear attributes in memory when ref is set, as they're needed
360+
// for the form to work properly in the editor. The save() method ensures that
361+
// only the ref attribute is persisted to the database.
362+
323363
// Track previous block count to detect insertions
324364
const previousBlockCountRef = useRef( currentInnerBlocks.length );
325365

@@ -809,18 +849,36 @@ function JetpackContactFormEdit( {
809849

810850
let elt;
811851

812-
if ( ! isModuleActive ) {
852+
// Show loading state when resolving synced form
853+
if ( ref && isResolvingSyncedForm ) {
854+
return (
855+
<div { ...blockProps }>
856+
<ContactFormSkeletonLoader />
857+
</div>
858+
);
859+
}
860+
// Show error if referenced form not found
861+
else if ( ref && ! syncedForm && ! isResolvingSyncedForm ) {
862+
elt = (
863+
<Notice status="warning" isDismissible={ false }>
864+
{ __( 'The referenced form could not be found.', 'jetpack-forms' ) }
865+
</Notice>
866+
);
867+
} else if ( ! isModuleActive ) {
813868
if ( isLoadingModules ) {
814-
elt = <ContactFormSkeletonLoader />;
815-
} else {
816-
elt = (
817-
<ContactFormPlaceholder
818-
changeStatus={ changeStatus }
819-
isModuleActive={ isModuleActive }
820-
isLoading={ isChangingStatus }
821-
/>
869+
return (
870+
<div { ...blockProps }>
871+
<ContactFormSkeletonLoader />
872+
</div>
822873
);
823874
}
875+
elt = (
876+
<ContactFormPlaceholder
877+
changeStatus={ changeStatus }
878+
isModuleActive={ isModuleActive }
879+
isLoading={ isChangingStatus }
880+
/>
881+
);
824882
} else if ( ! hasAnyInnerBlocks ) {
825883
elt = (
826884
<VariationPicker
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/**
2+
* Hook to auto-save editor changes back to synced form post
3+
*/
4+
5+
import { useEffect } from '@wordpress/element';
6+
import { FORM_POST_TYPE } from '../../shared/util/constants.js';
7+
import { serializeSyncedForm } from '../utils/synced-form-helpers.ts';
8+
9+
interface UseSyncedFormAutoSaveParams {
10+
ref?: number;
11+
syncedForm: { content?: { raw?: string } } | null;
12+
attributes: Record< string, unknown >;
13+
currentInnerBlocks: unknown[];
14+
isSyncingRef: React.MutableRefObject< boolean >;
15+
editEntityRecord: (
16+
kind: string,
17+
name: string,
18+
recordId: number,
19+
edits: Record< string, unknown >
20+
) => void;
21+
}
22+
23+
/**
24+
* Hook to automatically save changes from the editor back to the synced form post
25+
* Uses a debounce strategy to avoid excessive saves (1 second delay)
26+
* Only saves if content has changed and we're not currently loading
27+
*
28+
* @param {UseSyncedFormAutoSaveParams} params - Configuration parameters
29+
*/
30+
export function useSyncedFormAutoSave( {
31+
ref,
32+
syncedForm,
33+
attributes,
34+
currentInnerBlocks,
35+
isSyncingRef,
36+
editEntityRecord,
37+
}: UseSyncedFormAutoSaveParams ): void {
38+
useEffect( () => {
39+
if ( ! ref || ! syncedForm || isSyncingRef.current ) {
40+
return; // Not a synced form or currently syncing
41+
}
42+
43+
// Serialize the entire form block
44+
const serialized = serializeSyncedForm( attributes, currentInnerBlocks );
45+
46+
// Only update if content has changed
47+
if ( serialized !== syncedForm.content?.raw ) {
48+
// Debounce to avoid excessive saves
49+
const timeoutId = setTimeout( () => {
50+
editEntityRecord( 'postType', FORM_POST_TYPE, ref, {
51+
content: serialized,
52+
} );
53+
}, 1000 ); // 1 second debounce
54+
55+
return () => clearTimeout( timeoutId );
56+
}
57+
}, [ currentInnerBlocks, ref, syncedForm, editEntityRecord, attributes, isSyncingRef ] );
58+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/**
2+
* Hook to load synced form content into the editor (one-time sync on mount/ref change)
3+
*/
4+
5+
import { useEffect, useRef } from '@wordpress/element';
6+
import { filterSyncedAttributes } from '../utils/synced-form-helpers.ts';
7+
8+
interface UseSyncedFormLoaderParams {
9+
ref?: number;
10+
syncedFormBlocks: unknown[] | null;
11+
syncedFormAttributes: Record< string, unknown > | null;
12+
clientId: string;
13+
setAttributes: ( attributes: Record< string, unknown > ) => void;
14+
replaceInnerBlocks: ( clientId: string, blocks: unknown[], updateSelection: boolean ) => void;
15+
__unstableMarkNextChangeAsNotPersistent: () => void;
16+
}
17+
18+
interface UseSyncedFormLoaderResult {
19+
isSyncingRef: React.MutableRefObject< boolean >;
20+
}
21+
22+
/**
23+
* Hook to handle loading synced form content into the editor
24+
* This performs a one-time sync when the ref changes or loads for the first time
25+
* After loading, the user can edit freely and changes will be saved back via auto-save
26+
*
27+
* @param {UseSyncedFormLoaderParams} params - Configuration parameters
28+
* @return {UseSyncedFormLoaderResult} Object containing syncing state ref
29+
*/
30+
export function useSyncedFormLoader( {
31+
ref,
32+
syncedFormBlocks,
33+
syncedFormAttributes,
34+
clientId,
35+
setAttributes,
36+
replaceInnerBlocks,
37+
__unstableMarkNextChangeAsNotPersistent,
38+
}: UseSyncedFormLoaderParams ): UseSyncedFormLoaderResult {
39+
// Track if we're currently syncing to prevent save-back loops
40+
const isSyncingRef = useRef( false );
41+
const lastLoadedRefId = useRef< number | null >( null );
42+
43+
useEffect( () => {
44+
if ( ! ref || ! syncedFormBlocks ) {
45+
return;
46+
}
47+
48+
// Only sync when ref changes or loads for the first time
49+
// Don't re-sync when syncedFormBlocks changes due to our own edits
50+
if ( lastLoadedRefId.current === ref ) {
51+
return; // Already loaded this ref
52+
}
53+
54+
// Mark this ref as loaded
55+
lastLoadedRefId.current = ref;
56+
57+
// Sync on initial load
58+
// Once loaded, the user can edit freely and changes will save back to the source
59+
isSyncingRef.current = true;
60+
61+
// Apply form attributes from the synced form (except ref and layout attrs)
62+
// Mark as non-persistent so they're not saved locally - only ref is saved
63+
if ( syncedFormAttributes ) {
64+
const attrsToApply = filterSyncedAttributes( syncedFormAttributes );
65+
66+
__unstableMarkNextChangeAsNotPersistent();
67+
setAttributes( attrsToApply );
68+
}
69+
70+
// Load inner blocks from source
71+
__unstableMarkNextChangeAsNotPersistent();
72+
replaceInnerBlocks( clientId, syncedFormBlocks, false );
73+
74+
// Reset syncing flag after a short delay
75+
const timeoutId = setTimeout( () => {
76+
isSyncingRef.current = false;
77+
}, 100 );
78+
79+
return () => {
80+
clearTimeout( timeoutId );
81+
};
82+
}, [
83+
ref,
84+
syncedFormBlocks,
85+
syncedFormAttributes,
86+
clientId,
87+
__unstableMarkNextChangeAsNotPersistent,
88+
replaceInnerBlocks,
89+
setAttributes,
90+
] );
91+
92+
return { isSyncingRef };
93+
}

0 commit comments

Comments
 (0)