diff --git a/projects/packages/forms/changelog/fix-feedback-source-data b/projects/packages/forms/changelog/fix-feedback-source-data new file mode 100644 index 0000000000000..f5616aef97072 --- /dev/null +++ b/projects/packages/forms/changelog/fix-feedback-source-data @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Forms: Store the feedback source info with more context diff --git a/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php b/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php index 7a613264da59e..a0aa7d0023485 100644 --- a/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php +++ b/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php @@ -408,6 +408,16 @@ public function get_item_schema() { 'readonly' => true, ); + $schema['properties']['edit_form_url'] = array( + 'description' => __( 'The URL to edit the form.', 'jetpack-forms' ), + 'type' => 'string', + 'context' => array( 'view', 'edit', 'embed' ), + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_text_field', + ), + 'readonly' => true, + ); + $schema['properties']['subject'] = array( 'description' => __( 'The subject line of the form submission.', 'jetpack-forms' ), 'type' => 'string', @@ -621,6 +631,9 @@ public function prepare_item_for_response( $item, $request ) { if ( rest_is_field_included( 'entry_permalink', $fields ) ) { $data['entry_permalink'] = $response->get_entry_permalink(); } + if ( rest_is_field_included( 'edit_form_url', $fields ) ) { + $data['edit_form_url'] = $response->get_edit_form_url(); + } if ( rest_is_field_included( 'subject', $fields ) ) { $data['subject'] = $response->get_subject(); } diff --git a/projects/packages/forms/src/contact-form/class-contact-form.php b/projects/packages/forms/src/contact-form/class-contact-form.php index 32fba3dcb6a41..437591a80ca07 100644 --- a/projects/packages/forms/src/contact-form/class-contact-form.php +++ b/projects/packages/forms/src/contact-form/class-contact-form.php @@ -121,6 +121,13 @@ class Contact_Form extends Contact_Form_Shortcode { */ public $has_verified_jwt = false; + /** + * The source of the feedback entry. + * + * @var Feedback_Source + */ + private $source; + /** * Construction function. * @@ -243,6 +250,7 @@ public static function get_instance_from_jwt( $jwt_token ) { } $form = new self( $data['attributes'], $data['content'], empty( $data['attributes']['id'] ) ); + $form->source = Feedback_Source::from_serialized( $data['source'] ); $form->hash = $data['hash']; $form->has_verified_jwt = true; return $form; @@ -354,17 +362,32 @@ public function get_attributes() { * @return string The JWT token. */ public function get_jwt() { - $attributes = $this->attributes; + $attributes = $this->attributes; + $this->source = Feedback_Source::get_current( $attributes ); return JWT::encode( array( 'attributes' => $attributes, 'content' => $this->content, 'hash' => $this->hash, + 'source' => $this->source->serialize(), ), self::get_secret() ); } + /** + * Get the current source obejct. That is relevent to the form and there current request. + * + * @return Feedback_Source Return the current feedback source object. + */ + public function get_source() { + if ( ! $this->source ) { + $attributes = $this->attributes; + $this->source = Feedback_Source::get_current( $attributes ); + } + return $this->source; + } + /** * Get the count of forms. * @@ -829,10 +852,6 @@ class='" . esc_attr( $form_classes ) . "' $form_aria_label $r .= "\t\t\n"; $r .= "\t\t\n"; - if ( $page && $page > 1 ) { - $r .= "\t\t\n"; - } - if ( ! $has_submit_button_block ) { $r .= "\t

\n"; } @@ -1627,15 +1646,9 @@ public function get_field_ids() { * Stores feedback. Sends email. */ public function process_submission() { - $page_number = 1; - - // We skip the nonce verification for since nonce earlier in process_form_submission. - if ( isset( $_POST['page'] ) ) { // phpcs:Ignore WordPress.Security.NonceVerification.Missing - $page_number = absint( wp_unslash( $_POST['page'] ) ); // phpcs:Ignore WordPress.Security.NonceVerification.Missing - } - - $response = Feedback::from_submission( $_POST, $this, $this->current_post, $page_number ); // phpcs:Ignore WordPress.Security.NonceVerification.Missing + $response = Feedback::from_submission( $_POST, $this ); // phpcs:Ignore WordPress.Security.NonceVerification.Missing + $response->set_source( $this->get_source() ); $plugin = Contact_Form_Plugin::init(); $id = $this->get_attribute( 'id' ); diff --git a/projects/packages/forms/src/contact-form/class-feedback-source.php b/projects/packages/forms/src/contact-form/class-feedback-source.php index 00183571427a8..45f34001ae275 100644 --- a/projects/packages/forms/src/contact-form/class-feedback-source.php +++ b/projects/packages/forms/src/contact-form/class-feedback-source.php @@ -17,9 +17,9 @@ class Feedback_Source { /** * The ID of the post or page that the feedback was created on. * - * @var int + * @var string */ - private $id = 0; + private $id = ''; /** * The title of the post or page that the feedback was created on. @@ -43,29 +43,62 @@ class Feedback_Source { */ private $page_number = 1; + /** + * The source type of the feedback entry. + * Possible values: single, widget, block_template, block_template_part + * + * @var string + */ + private $source_type = 'single'; + + /** + * The request URL of the feedback entry. + * + * @var string + */ + private $request_url = ''; + /** * Constructor for Feedback_Source. * - * @param int $id The ID of the feedback entry. - * @param string $title The title of the feedback entry. - * @param int $page_number The page number of the feedback entry, default is 1. + * @param string|int $id The Source ID = post ID, widget ID, block template ID, or 0 for homepage or non-post/page. + * @param string $title The title of the feedback entry. + * @param int $page_number The page number of the feedback entry, default is 1. + * @param string $source_type The source type of the feedback entry, default is 'single'. + * @param string $request_url The request URL of the feedback entry. */ - public function __construct( $id, $title, $page_number = 1 ) { + public function __construct( $id = 0, $title = '', $page_number = 1, $source_type = 'single', $request_url = '' ) { + + if ( is_numeric( $id ) ) { + $this->id = $id > 0 ? $id : 0; + } else { + $this->id = $id; + } - $this->id = $id > 0 ? (int) $id : 0; $this->title = $title; $this->page_number = $page_number; - $this->permalink = $this->id === 0 ? home_url() : ''; - - if ( $id <= 0 ) { - return; - } + $this->permalink = empty( $request_url ) ? home_url() : $request_url; + $this->source_type = $source_type; // possible source types: single, widget, block_template, block_template_part + $this->request_url = $request_url; - $entry_post = get_post( $id ); + if ( is_numeric( $id ) && ! empty( $id ) ) { + $entry_post = get_post( (int) $id ); + if ( $entry_post && $entry_post->post_status === 'publish' ) { + $this->permalink = get_permalink( $entry_post ); + $this->title = get_the_title( $entry_post ); + } elseif ( $entry_post ) { + $this->permalink = ''; - if ( $entry_post && $entry_post->post_status === 'publish' ) { - $this->permalink = get_permalink( $entry_post ); - $this->title = get_the_title( $entry_post ); + if ( $entry_post->post_status === 'trash' ) { + /* translators: %s is the post title */ + $this->title = sprintf( __( '(trashed) %s', 'jetpack-forms' ), $this->title ); + } + } + if ( empty( $entry_post ) ) { + /* translators: %s is the post title */ + $this->title = sprintf( __( '(deleted) %s', 'jetpack-forms' ), $this->title ); + $this->permalink = ''; + } } } @@ -83,11 +116,81 @@ public static function from_submission( $current_post, int $current_page_number return new self( 0, '', $current_page_number ); } - $title = $current_post->post_title ?? ''; + $title = $current_post->post_title ?? __( '(no title)', 'jetpack-forms' ); return new self( $id, $title, $current_page_number ); } + /** + * Get the title of the current page. That we can then use to display in the feedback entry. + * + * @return string The title of the current page. That we want to show to the user. To tell them where the feedback was left. + */ + private static function get_source_title() { + if ( is_front_page() ) { + return get_bloginfo( 'name' ); + } + if ( is_home() ) { + return get_the_title( get_option( 'page_for_posts', true ) ); + } + if ( is_singular() ) { + return get_the_title(); + } + if ( is_archive() ) { + return get_the_archive_title(); + } + if ( is_search() ) { + /* translators: %s is the search term */ + return sprintf( __( 'Search results for: %s', 'jetpack-forms' ), get_search_query() ); + } + if ( is_404() ) { + return __( '404 Not Found', 'jetpack-forms' ); + } + return get_bloginfo( 'name' ); + } + + /** + * Creates a Feedback_Source instance for a block template. + * + * @param array $attributes Form Shortcode attributes. + * + * @return Feedback_Source Returns an instance of Feedback_Source. + */ + public static function get_current( $attributes ) { + global $wp, $page; + $current_url = home_url( add_query_arg( array(), $wp->request ) ); + if ( isset( $attributes['widget'] ) && ! empty( $attributes['widget'] ) ) { + return new self( $attributes['widget'], self::get_source_title(), 1, 'widget', $current_url ); + } + + if ( isset( $attributes['block_template'] ) && ! empty( $attributes['block_template'] ) ) { + global $_wp_current_template_id; + return new self( $_wp_current_template_id, self::get_source_title(), $page, 'block_template', $current_url ); + } + + if ( isset( $attributes['block_template_part'] ) && ! empty( $attributes['block_template_part'] ) ) { + return new self( $attributes['block_template_part'], self::get_source_title(), $page, 'block_template_part', $current_url ); + } + + return new Feedback_Source( \get_the_ID(), \get_the_title(), $page, 'single', $current_url ); + } + + /** + * Creates a Feedback_Source instance from serialized data. + * + * @param array $data The serialized data. + * @return Feedback_Source Returns an instance of Feedback_Source. + */ + public static function from_serialized( $data ) { + $id = $data['source_id'] ?? 0; + $title = $data['entry_title'] ?? ''; + $page_number = $data['entry_page'] ?? 1; + $source_type = $data['source_type'] ?? 'single'; + $request_url = $data['request_url'] ?? ''; + + return new self( $id, $title, $page_number, $source_type, $request_url ); + } + /** * Get the permalink of the feedback entry. * @@ -100,6 +203,38 @@ public function get_permalink() { return $this->permalink; } + /** + * Get the edit URL of the form or page where the feedback was submitted from. + * + * @return string The edit URL of the form or page. + */ + public function get_edit_form_url() { + + if ( current_user_can( 'edit_theme_options' ) ) { + if ( $this->source_type === 'block_template' && \wp_is_block_theme() ) { + return admin_url( 'site-editor.php?p=' . esc_attr( '/wp_template/' . addslashes( $this->id ) ) . '&canvas=edit' ); + } + + if ( $this->source_type === 'block_template_part' && \wp_is_block_theme() ) { + return admin_url( 'site-editor.php?p=' . esc_attr( '/wp_template_part/' . addslashes( $this->id ) ) . '&canvas=edit' ); + } + + if ( $this->source_type === 'widget' && current_theme_supports( 'widgets' ) ) { + return admin_url( 'widgets.php' ); + } + } + + if ( $this->id && is_numeric( $this->id ) && $this->id > 0 && current_user_can( 'edit_post', (int) $this->id ) ) { + $entry_post = get_post( (int) $this->id ); + if ( $entry_post && $entry_post->post_status === 'trash' ) { + return ''; // No edit link is possible for trashed posts. They need to be restored first. + } + return \get_edit_post_link( (int) $this->id, 'url' ); + } + + return ''; + } + /** * Get the relative permalink of the feedback entry. * @@ -131,7 +266,7 @@ public function get_title() { /** * Get the post id of the feedback entry. * - * @return int The ID of the feedback entry. + * @return int|string The ID of the feedback entry. */ public function get_id() { return $this->id; @@ -146,6 +281,9 @@ public function serialize() { return array( 'entry_title' => $this->title, 'entry_page' => $this->page_number, + 'source_id' => $this->id, + 'source_type' => $this->source_type, + 'request_url' => $this->request_url, ); } } diff --git a/projects/packages/forms/src/contact-form/class-feedback.php b/projects/packages/forms/src/contact-form/class-feedback.php index 876b3debe3762..81c1f787cff6b 100644 --- a/projects/packages/forms/src/contact-form/class-feedback.php +++ b/projects/packages/forms/src/contact-form/class-feedback.php @@ -156,11 +156,14 @@ private function load_from_post( WP_Post $feedback_post ) { $this->feedback_time = $feedback_post->post_date; $this->fields = $parsed_content['fields'] ?? array(); + $source_id = $feedback_post->post_parent ? (int) $feedback_post->post_parent : 0; $this->source = new Feedback_Source( - $feedback_post->post_parent, + $parsed_content['source_id'] ?? $source_id, $parsed_content['entry_title'] ?? '', - $parsed_content['entry_page'] ?? 1 + $parsed_content['entry_page'] ?? 1, + $parsed_content['source_type'] ?? 'single', + $parsed_content['request_url'] ?? '' ); $this->ip_address = $parsed_content['ip'] ?? $this->get_first_field_of_type( 'ip' ); @@ -194,6 +197,15 @@ public static function from_submission( $post_data, $form, $current_post = null, return $instance; } + /** + * Set the source of the feedback entry. + * + * @param Feedback_Source $source The source object. + */ + public function set_source( $source ) { + $this->source = $source; + } + /** * Load from Form Submission. * @@ -767,7 +779,7 @@ public function set_status( $status ) { * * This is the post ID of the post or page that the feedback was submitted from. * - * @return int|null + * @return int|string */ public function get_entry_id() { return $this->source->get_id(); @@ -793,6 +805,15 @@ public function get_entry_title() { public function get_entry_permalink() { return $this->source->get_permalink(); } + + /** + * Get the editor URL where the user can edit the form. + * + * @return string + */ + public function get_edit_form_url() { + return $this->source->get_edit_form_url(); + } /** * Get the short permalink of a post. * diff --git a/projects/packages/forms/src/dashboard/inbox/dataviews/actions.js b/projects/packages/forms/src/dashboard/inbox/dataviews/actions.js index 56ea8675cc452..a28c19e201d9a 100644 --- a/projects/packages/forms/src/dashboard/inbox/dataviews/actions.js +++ b/projects/packages/forms/src/dashboard/inbox/dataviews/actions.js @@ -27,6 +27,22 @@ export const viewActionModal = { }, }; +export const editFormAction = { + id: 'edit-form', + label: __( 'Edit form', 'jetpack-forms' ), + icon: , + isEligible: item => !! item?.edit_form_url, + supportsBulk: false, + async callback( items ) { + const [ item ] = items; + if ( item?.edit_form_url ) { + const url = new URL( item.edit_form_url, window.location.origin ); + // redirect to the form edit page + window.location.href = url.toString(); + } + }, +}; + // TODO: We should probably have better error messages in case of failure. const getGenericErrorMessage = numberOfErrors => { return numberOfErrors.length === 1 diff --git a/projects/packages/forms/src/dashboard/inbox/dataviews/index.js b/projects/packages/forms/src/dashboard/inbox/dataviews/index.js index 1cd9d5d5c3c58..fe47e687940ec 100644 --- a/projects/packages/forms/src/dashboard/inbox/dataviews/index.js +++ b/projects/packages/forms/src/dashboard/inbox/dataviews/index.js @@ -32,6 +32,7 @@ import { moveToTrashAction, deleteAction, restoreAction, + editFormAction, } from './actions'; import { useView, defaultLayouts } from './views'; @@ -284,6 +285,7 @@ export default function InboxView() { markAsSpamAction, markAsNotSpamAction, moveToTrashAction, + editFormAction, restoreAction, deleteAction, ]; diff --git a/projects/packages/forms/tests/php/contact-form/Contact_Form_Plugin_Test.php b/projects/packages/forms/tests/php/contact-form/Contact_Form_Plugin_Test.php index d093975418c1c..8391b032c4659 100644 --- a/projects/packages/forms/tests/php/contact-form/Contact_Form_Plugin_Test.php +++ b/projects/packages/forms/tests/php/contact-form/Contact_Form_Plugin_Test.php @@ -785,7 +785,6 @@ public function test_get_export_feedback_data_structure() { } public function test_interpersonal_data_exporter() { - global $post; $post_id = Utility::create_legacy_feedback( array( @@ -809,11 +808,11 @@ public function test_interpersonal_data_exporter() { ), array( 'name' => 'Source Title', - 'value' => 'Cool Post Title', // the default value in the create_legacy_feedback + 'value' => '(deleted) Cool Post Title', // the default value in the create_legacy_feedback ), array( 'name' => 'Source URL:', - 'value' => get_permalink( $post->ID ), + 'value' => '', ), array( 'name' => 'field', diff --git a/projects/packages/forms/tests/php/contact-form/Contact_Form_Test.php b/projects/packages/forms/tests/php/contact-form/Contact_Form_Test.php index 76ebef841cd71..d4ec973b50663 100644 --- a/projects/packages/forms/tests/php/contact-form/Contact_Form_Test.php +++ b/projects/packages/forms/tests/php/contact-form/Contact_Form_Test.php @@ -2748,6 +2748,8 @@ public function test_get_instance_from_jwt_returns_with_all_attribute_data() { $this->assertSame( '12345', $form_copy->get_attribute( 'salesforceData' )['organizationId'], 'organizationId should match' ); $this->assertEquals( $expected_attributes, $form_copy->get_attributes(), 'jetpackCRM should be true' ); + + $this->assertEquals( $form->get_source(), $form_copy->get_source(), 'Form sources should match' ); } public function test_get_instance_from_jwt_returns_null_for_invalid_jwt() { diff --git a/projects/packages/forms/tests/php/contact-form/Feedback_Source_Test.php b/projects/packages/forms/tests/php/contact-form/Feedback_Source_Test.php index a672233805e32..b0557a5796540 100644 --- a/projects/packages/forms/tests/php/contact-form/Feedback_Source_Test.php +++ b/projects/packages/forms/tests/php/contact-form/Feedback_Source_Test.php @@ -38,9 +38,9 @@ public function test_constructor_with_negative_id() { $entry = new Feedback_Source( -5, 'Test Title' ); $this->assertSame( 0, $entry->get_id() ); - $this->assertEquals( 'Test Title', $entry->get_title() ); + $this->assertEquals( '(deleted) Test Title', $entry->get_title() ); $this->assertSame( 1, $entry->get_page_number() ); - $this->assertSame( home_url(), $entry->get_permalink() ); + $this->assertSame( '', $entry->get_permalink() ); } /** @@ -50,7 +50,7 @@ public function test_constructor_with_nonexistent_post() { $entry = new Feedback_Source( 999999, 'Fallback Title' ); $this->assertSame( 999999, $entry->get_id() ); - $this->assertEquals( 'Fallback Title', $entry->get_title() ); + $this->assertEquals( '(deleted) Fallback Title', $entry->get_title() ); $this->assertSame( 1, $entry->get_page_number() ); $this->assertSame( '', $entry->get_permalink() ); $this->assertSame( '', $entry->get_relative_permalink() ); @@ -119,15 +119,26 @@ public function test_from_submission_with_missing_id() { * Test from_submission with post missing title */ public function test_from_submission_with_missing_title() { - $post = new \WP_Post( - (object) array( - 'ID' => 456, + + $post_id = wp_insert_post( + array( + 'post_status' => 'publish', + 'post_type' => 'post', + 'post_content' => 'Content without title', + 'post_title' => 'howdy', + ) + ); + wp_update_post( + array( + 'ID' => $post_id, + 'post_title' => '', ) ); + $post = \get_post( $post_id ); $entry = Feedback_Source::from_submission( $post, 3 ); - $this->assertEquals( 456, $entry->get_id() ); + $this->assertEquals( $post_id, $entry->get_id() ); $this->assertSame( '', $entry->get_title() ); $this->assertEquals( 3, $entry->get_page_number() ); } @@ -243,12 +254,18 @@ public function test_serialize() { $expected = array( 'entry_title' => 'Serialized Title', 'entry_page' => 2, + 'source_id' => 0, + 'source_type' => 'single', + 'request_url' => '', ); $this->assertEquals( $expected, $serialized ); $this->assertIsArray( $serialized ); $this->assertArrayHasKey( 'entry_title', $serialized ); $this->assertArrayHasKey( 'entry_page', $serialized ); + + $new_source = Feedback_Source::from_serialized( $serialized ); + $this->assertEquals( $serialized, $new_source->serialize() ); } /** @@ -261,9 +278,15 @@ public function test_serialize_with_empty_title() { $expected = array( 'entry_title' => '', 'entry_page' => 1, + 'source_id' => 0, + 'source_type' => 'single', + 'request_url' => '', ); $this->assertEquals( $expected, $serialized ); + + $new_source = Feedback_Source::from_serialized( $serialized ); + $this->assertEquals( $expected, $new_source->serialize() ); } /** diff --git a/projects/packages/forms/tests/php/contact-form/Feedback_Test.php b/projects/packages/forms/tests/php/contact-form/Feedback_Test.php index a3bc52b097389..4b7e80eec9f74 100644 --- a/projects/packages/forms/tests/php/contact-form/Feedback_Test.php +++ b/projects/packages/forms/tests/php/contact-form/Feedback_Test.php @@ -1072,7 +1072,7 @@ public function test_compute_entry_title_deleted() { $saved_response = Feedback::get( $post_id ); $this->assertNotEmpty( $saved_response->get_entry_title(), 'Post Title should NOT be empty after the post is deleted' ); - $this->assertEquals( $current_post->post_title, $saved_response->get_entry_title(), 'Post Title should match the saved form submission Original post title' ); + $this->assertEquals( '(deleted) ' . $current_post->post_title, $saved_response->get_entry_title(), 'Post Title should match the saved form submission Original post title' ); } public function test_get_all_values() { @@ -1390,8 +1390,10 @@ public function test_compute_entry_permalink_deleted_post() { "[contact-field label='Email' type='email' required='1'/]" ); - $response = Feedback::from_submission( $_post_data, $form, $current_post ); - $post_id = $response->save(); + $response = Feedback::from_submission( $_post_data, $form ); + $response->set_source( new Feedback_Source( $current_post->ID, $current_post->post_title, 1, 'single', home_url( '?p=' . $current_post->ID ) ) ); + + $post_id = $response->save(); Utility::destroy_post_context( $current_post ); // Destroy the post context to simulate a deleted post. $saved_response = Feedback::get( $post_id ); $this->assertEmpty( $saved_response->get_entry_permalink(), 'Post permalink should match the form submission' ); @@ -1746,6 +1748,17 @@ public function test_file_uploads() { } public function test_legacy_get_all_legacy_values() { + // Setup the post context. + $holding_post_id = wp_insert_post( + array( + 'post_type' => 'post', + 'post_title' => 'Cool Post Title', + 'post_status' => 'publish', + ) + ); + global $post; + $post = get_post( $holding_post_id ); + $post_id = Utility::create_legacy_feedback( array( '1_field' => 'value1', @@ -1766,7 +1779,7 @@ public function test_legacy_get_all_legacy_values() { '2_field' => 'value2', 'email_marketing_consent' => 'no', 'entry_title' => 'Cool Post Title', - 'entry_permalink' => '', + 'entry_permalink' => 'http://example.org/?p=' . $holding_post_id, 'feedback_id' => 'skip', ), ); diff --git a/projects/plugins/jetpack/changelog/fix-feedback-source-data b/projects/plugins/jetpack/changelog/fix-feedback-source-data new file mode 100644 index 0000000000000..c0bc735a177ed --- /dev/null +++ b/projects/plugins/jetpack/changelog/fix-feedback-source-data @@ -0,0 +1,4 @@ +Significance: patch +Type: bugfix + +Forms: Store the feedback source info with more context