__( 'Forms', 'jetpack-forms' ) ) );
$patterns = array(
'contact-form' => array(
'title' => __( 'Contact Form', 'jetpack-forms' ),
'blockTypes' => array( 'jetpack/contact-form' ),
'categories' => array( $category_slug ),
'content' => '
',
),
'newsletter-form' => array(
'title' => __( 'Lead Capture Form', 'jetpack-forms' ),
'blockTypes' => array( 'jetpack/contact-form' ),
'categories' => array( $category_slug ),
'content' => '
',
),
'rsvp-form' => array(
'title' => __( 'RSVP Form', 'jetpack-forms' ),
'blockTypes' => array( 'jetpack/contact-form' ),
'categories' => array( $category_slug ),
'content' => '
',
),
'registration-form' => array(
'title' => __( 'Registration Form', 'jetpack-forms' ),
'blockTypes' => array( 'jetpack/contact-form' ),
'categories' => array( $category_slug ),
'content' => '
',
),
'appointment-form' => array(
'title' => __( 'Appointment Form', 'jetpack-forms' ),
'blockTypes' => array( 'jetpack/contact-form' ),
'categories' => array( $category_slug ),
'content' => '
',
),
'feedback-form' => array(
'title' => __( 'Feedback Form', 'jetpack-forms' ),
'blockTypes' => array( 'jetpack/contact-form' ),
'categories' => array( $category_slug ),
'content' => '
',
),
'salesforce-lead-form' => array(
'title' => __( 'Salesforce Lead Form', 'jetpack-forms' ),
'blockTypes' => array( 'jetpack/contact-form' ),
'categories' => array( $category_slug ),
'content' => '
',
),
);
/*
* The Lead Capture pattern is the twin of the 'lead-capture-form' block variation, which is
* hidden on WordPress.com Simple and WoA sites: there the Subscribe block owns newsletter
* signups, and a form with a Subscribe button subscribes nobody. Drop the pattern on those
* hosts too, so it isn't the last remaining route into a form that looks like a signup and
* silently isn't one.
*/
if ( ( new Host() )->is_wpcom_platform() ) {
unset( $patterns['newsletter-form'] );
}
foreach ( $patterns as $name => $pattern ) {
register_block_pattern( $name, $pattern );
}
}
/**
* Sets the 'block_template' attribute on all instances of wp:jetpack/contact-form in
* the $_wp_current_template_content global variable.
*
* The $_wp_current_template_content global variable is hydrated immediately prior to
* 'template_include' in wp-includes/template-loader.php.
*
* This fixes Contact Form Blocks added to FSE _templates_ (e.g. Single or 404).
*
* @param string $template Template to be loaded.
*/
public static function grunion_contact_form_set_block_template_attribute( $template ) {
global $_wp_current_template_content;
if ( ! is_string( $template ) ) {
return $template;
}
if ( 'template-canvas.php' === basename( $template ) ) {
Contact_Form::style_on();
$_wp_current_template_content = self::grunion_contact_form_apply_block_attribute(
$_wp_current_template_content,
array(
'block_template' => 'canvas',
)
);
// Mark that we are rendering a block template, so forms in the template chrome are
// attributed to it. This global is the trusted signal Feedback_Source::get_current()
// uses for the block_template source type (the content attribute is not trusted). It
// is suspended while core/post-content renders so a form in the post body is not
// mistaken for a template-authored form.
global $_wp_current_template_id;
$GLOBALS['grunion_block_template_id'] = ! empty( $_wp_current_template_id ) ? $_wp_current_template_id : 'canvas';
}
return $template;
}
/**
* Sets the $grunion_block_template_part_id global.
*
* This is part of the fix for Contact Form Blocks added to FSE _template parts_ (e.g footer).
* The global is processed in Contact_Form::parse().
*
* @param string $template_part_id ID for the currently rendered template part.
*/
public static function grunion_contact_form_set_block_template_part_id_global( $template_part_id ) {
$GLOBALS['grunion_block_template_part_id'] = $template_part_id;
}
/**
* Unsets the global when block is done rendering.
*
* @param string $content Rendered block content.
* @param array $block The full block, including name and attributes.
* @return string
*/
public static function grunion_contact_form_unset_block_template_part_id_global( $content, $block ) {
if ( isset( $block['blockName'] )
&& 'core/template-part' === $block['blockName']
&& isset( $GLOBALS['grunion_block_template_part_id'] ) ) {
unset( $GLOBALS['grunion_block_template_part_id'] );
}
return $content;
}
/**
* Suspends the block_template global while a core/post-content block renders.
*
* The core/post-content block renders the post body, which may contain a contact form
* authored by a user without edit_theme_options. Such a form must be attributed to the post
* (and gated on the post author), not to the surrounding template, so the trusted
* block_template signal is removed for the duration of the render and restored afterwards.
*
* Hooked on `pre_render_block`; returns its first argument unchanged so rendering proceeds.
*
* @param string|null $pre_render The pre-rendered content. Default null.
* @param array $parsed_block The block being rendered.
* @return string|null Unchanged $pre_render.
*/
public static function grunion_contact_form_suspend_block_template_id_in_post_content( $pre_render, $parsed_block ) {
if ( isset( $parsed_block['blockName'] ) && 'core/post-content' === $parsed_block['blockName'] ) {
self::$block_template_id_suspended[] = $GLOBALS['grunion_block_template_id'] ?? null;
unset( $GLOBALS['grunion_block_template_id'] );
}
return $pre_render;
}
/**
* Restores the block_template global once a core/post-content block has finished rendering.
*
* Counterpart to grunion_contact_form_suspend_block_template_id_in_post_content(). Hooked on
* `render_block`; returns the block content unchanged.
*
* @param string $content Rendered block content.
* @param array $block The full block, including name and attributes.
* @return string Unchanged $content.
*/
public static function grunion_contact_form_restore_block_template_id_after_post_content( $content, $block ) {
if ( isset( $block['blockName'] )
&& 'core/post-content' === $block['blockName']
&& ! empty( self::$block_template_id_suspended ) ) {
$restored = array_pop( self::$block_template_id_suspended );
if ( null !== $restored ) {
$GLOBALS['grunion_block_template_id'] = $restored;
}
}
return $content;
}
/**
* Sets the 'widget' attribute on all instances of the contact form in the widget block.
*
* @param string $content Existing widget block content.
* @param array $instance Array of settings for the current widget.
* @param \WP_Widget_Block $widget Current Block widget instance.
* @return string
*/
public static function grunion_contact_form_filter_widget_block_content( $content, $instance, $widget ) {
Contact_Form::style_on();
// Inject 'block_template' => into all instances of the contact form block.
return self::grunion_contact_form_apply_block_attribute(
$content,
array(
'widget' => $widget->id,
)
);
}
/**
* Deletes old spam feedbacks to keep the posts table size under control.
*/
public static function grunion_delete_old_spam() {
global $wpdb;
$grunion_delete_limit = 100;
$now_gmt = current_time( 'mysql', true );
// Use the spam status changed date if available, otherwise fall back to post_date_gmt for backward compatibility
$sql = $wpdb->prepare(
"
SELECT p.`ID`
FROM $wpdb->posts p
LEFT JOIN $wpdb->postmeta pm ON p.`ID` = pm.`post_id` AND pm.`meta_key` = '_spam_status_changed_gmt'
WHERE DATE_SUB( %s, INTERVAL 15 DAY ) > COALESCE( pm.`meta_value`, p.`post_date_gmt` )
AND p.`post_type` = 'feedback'
AND p.`post_status` = 'spam'
LIMIT %d
",
$now_gmt,
$grunion_delete_limit
);
$post_ids = $wpdb->get_col( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
foreach ( (array) $post_ids as $post_id ) {
// force a full delete, skip the trash
wp_delete_post( $post_id, true );
}
if (
/**
* Filter if the module run OPTIMIZE TABLE on the core WP tables.
*
* @module contact-form
*
* @since 1.3.1
* @since 6.4.0 Set to false by default.
*
* @param bool $filter Should Jetpack optimize the table, defaults to false.
*/
apply_filters( 'grunion_optimize_table', false )
) {
$wpdb->query( "OPTIMIZE TABLE $wpdb->posts" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
}
// if we hit the max then schedule another run
if ( count( $post_ids ) >= $grunion_delete_limit ) {
wp_schedule_single_event( time() + 700, 'grunion_scheduled_delete' );
}
}
/**
* Deletes old temp feedback to keep the posts table size under control.
*
* @since 6.5.0
*/
public static function grunion_delete_old_temp_feedback() {
global $wpdb;
$grunion_delete_limit = 100;
$now_gmt = current_time( 'mysql', true );
$sql = $wpdb->prepare(
"
SELECT `ID`
FROM $wpdb->posts
WHERE DATE_SUB( %s, INTERVAL 1 DAY ) > `post_date_gmt`
AND `post_type` = 'feedback'
AND `post_status` = 'jp-temp-feedback'
LIMIT %d
",
$now_gmt,
$grunion_delete_limit
);
// The SQL query is already prepared with $wpdb->prepare() above, and direct query is needed for performance-critical cleanup operation
$post_ids = $wpdb->get_col( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
foreach ( (array) $post_ids as $post_id ) {
// force a full delete, skip the trash
wp_delete_post( $post_id, true );
}
if (
/**
* Filter if the module run OPTIMIZE TABLE on the core WP tables.
*
* @module contact-form
*
* @since 6.5.0
*
* @param bool $filter Should Jetpack optimize the table, defaults to false.
*/
apply_filters( 'grunion_optimize_table', false )
) {
// OPTIMIZE TABLE is a MySQL-specific maintenance command that cannot be prepared and is only run when explicitly enabled via filter
$wpdb->query( "OPTIMIZE TABLE $wpdb->posts" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
}
// if we hit the max then schedule another run
if ( count( $post_ids ) >= $grunion_delete_limit ) {
wp_schedule_single_event( time() + 700, 'grunion_scheduled_delete_temp' );
}
}
/**
* Send an event to Tracks on form submission.
*
* @param int $post_id - the post_id for the CPT that is created.
* @param array $all_values - array containing all form fields.
* @param array $extra_values - array containing extra form metadata.
*
* @return null|void
*/
public static function jetpack_tracks_record_grunion_pre_message_sent( $post_id, $all_values = array(), $extra_values = array() ) {
$post = get_post( $post_id );
if ( $post ) {
$extra = gmdate( 'Y-W', strtotime( $post->post_date_gmt ) );
} else {
$extra = 'no-post';
}
/** This action is documented in jetpack/modules/widgets/social-media-icons.php */
do_action( 'jetpack_bump_stats_extras', 'jetpack_forms_message_sent', $extra );
$form_type = isset( $extra_values['widget'] ) ? 'widget' : 'block';
$context = '';
if ( isset( $extra_values['block_template'] ) ) {
$context = 'template';
} elseif ( isset( $extra_values['block_template_part'] ) ) {
$context = 'template_part';
}
$plugin = Contact_Form_Plugin::init();
$plugin->record_tracks_event(
'jetpack_forms_message_sent',
array(
'post_id' => $post_id,
'form_type' => $form_type,
'context' => $context,
'has_consent' => empty( $all_values['email_marketing_consent'] ) ? 0 : 1,
)
);
}
/**
* Adds a given attribute to all instances of the Contact Form block.
*
* @param string $content Existing content to process.
* @param array $new_attr New attributes to add.
* @return string
*/
public static function grunion_contact_form_apply_block_attribute( $content, $new_attr ) {
if ( ! is_string( $content ) ) {
// If the content is not a string, we cannot process it.
return $content;
}
if ( false === stripos( $content, 'wp:jetpack/contact-form' ) ) {
return $content;
}
// Parse blocks using WordPress core function.
$blocks = parse_blocks( $content );
// Recursively modify contact form blocks.
$modified_blocks = self::modify_contact_form_blocks_recursive( $blocks, $new_attr );
// Serialize back to block markup.
return serialize_blocks( $modified_blocks );
}
/**
* Recursively modifies contact form blocks to add new attributes.
*
* @param array $blocks Array of parsed blocks.
* @param array $new_attr New attributes to add.
* @return array Modified blocks array.
*/
private static function modify_contact_form_blocks_recursive( $blocks, $new_attr ) {
foreach ( $blocks as &$block ) {
// Check if this is a contact form block.
if ( 'jetpack/contact-form' === $block['blockName'] ) {
// Merge new attributes with existing ones.
$block['attrs'] = array_merge(
$block['attrs'] ?? array(),
$new_attr
);
}
// Recursively process inner blocks.
if ( ! empty( $block['innerBlocks'] ) ) {
$block['innerBlocks'] = self::modify_contact_form_blocks_recursive(
$block['innerBlocks'],
$new_attr
);
}
}
return $blocks;
}
/**
* Get a filename for export tasks
*
* @param string $source The filtered source for exported data.
* @return string The filename without source nor date suffix.
*/
public static function get_export_filename( $source = '' ) {
return $source === ''
? sprintf(
/* translators: Site title, used to craft the export filename, eg "MySite - Jetpack Form Responses" */
__( '%s - Jetpack Form Responses', 'jetpack-forms' ),
sanitize_file_name( get_bloginfo( 'name' ) )
)
: sprintf(
/* translators: 1: Site title; 2: post title. Used to craft the export filename, eg "MySite - Jetpack Form Responses - Contact" */
__( '%1$s - Jetpack Form Responses - %2$s', 'jetpack-forms' ),
sanitize_file_name( get_bloginfo( 'name' ) ),
sanitize_file_name( html_entity_decode( $source, ENT_QUOTES | ENT_HTML5, 'UTF-8' ) )
);
}
/**
* Ensures a field label ends with a colon, unless it ends with a question mark.
*
* @param string $label The field label.
* @return string The formatted label.
*/
public static function maybe_add_colon_to_label( $label ) {
$formatted_label = $label ? $label : '';
// Special case for the Terms consent field block which a period after the label.
$formatted_label = str_ends_with( $formatted_label, '?' ) ? $formatted_label : rtrim( $formatted_label, ':.' ) . ':';
return $formatted_label;
}
}