diff --git a/projects/packages/jetpack-mu-wpcom/changelog/remove-start-writing-checklist b/projects/packages/jetpack-mu-wpcom/changelog/remove-start-writing-checklist new file mode 100644 index 000000000000..d1e41d04072f --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/changelog/remove-start-writing-checklist @@ -0,0 +1,4 @@ +Significance: minor +Type: removed + +Remove the deprecated start-writing launchpad checklist and its remaining task handling. diff --git a/projects/packages/jetpack-mu-wpcom/src/features/launchpad/launchpad-task-definitions.php b/projects/packages/jetpack-mu-wpcom/src/features/launchpad/launchpad-task-definitions.php index 59037d14c277..ab7dd04bffa8 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/launchpad/launchpad-task-definitions.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/launchpad/launchpad-task-definitions.php @@ -1833,9 +1833,9 @@ function wpcom_track_site_launch_task() { wpcom_launchpad_mark_launchpad_task_complete_if_active( 'videopress_launched' ); wpcom_launchpad_mark_launchpad_task_complete_if_active( 'blog_launched' ); - // Remove site intent for blog onboarding flows and disable launchpad. + // Remove site intent for the blog onboarding flow and disable launchpad. $site_intent = get_option( 'site_intent' ); - if ( in_array( $site_intent, array( 'start-writing', 'design-first' ), true ) ) { + if ( 'design-first' === $site_intent ) { update_option( 'site_intent', '' ); update_option( 'launchpad_screen', 'off' ); } @@ -1996,15 +1996,6 @@ function wpcom_launchpad_is_blog_launched_task_disabled() { } return true; } - if ( 'start-writing' === get_option( 'site_intent' ) ) { - if ( wpcom_is_checklist_task_complete( 'plan_completed' ) - && wpcom_is_checklist_task_complete( 'domain_upsell' ) - && wpcom_is_checklist_task_complete( 'setup_blog' ) - && wpcom_is_checklist_task_complete( 'first_post_published' ) ) { - return false; - } - return true; - } return false; } @@ -2819,7 +2810,7 @@ function wpcom_trigger_email_campaign() { return; } - if ( ! in_array( $site_intent, array( 'start-writing', 'design-first' ), true ) ) { + if ( 'design-first' !== $site_intent ) { return; } diff --git a/projects/packages/jetpack-mu-wpcom/src/features/launchpad/launchpad.php b/projects/packages/jetpack-mu-wpcom/src/features/launchpad/launchpad.php index ebab614cfade..129e7810427e 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/launchpad/launchpad.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/launchpad/launchpad.php @@ -151,19 +151,6 @@ function wpcom_launchpad_get_task_list_definitions() { ), 'is_enabled_callback' => 'wpcom_launchpad_get_fullscreen_enabled', ), - 'start-writing' => array( - 'get_title' => function () { - return __( 'Next steps for your site', 'jetpack-mu-wpcom' ); - }, - 'task_ids' => array( - 'first_post_published', - 'setup_blog', - 'domain_upsell', - 'plan_completed', - 'blog_launched', - ), - 'is_enabled_callback' => 'wpcom_launchpad_get_fullscreen_enabled', - ), 'design-first' => array( 'get_title' => function () { return __( 'Next steps for your site', 'jetpack-mu-wpcom' ); @@ -440,6 +427,20 @@ function wpcom_register_default_launchpad_checklists() { } add_action( 'init', 'wpcom_register_default_launchpad_checklists', 11 ); +/** + * The site intents of retired onboarding flows. + * + * These no longer have a checklist, but sites created under them still carry + * the value, and clients still pass it to the launchpad endpoint as a checklist + * slug. The endpoint keeps accepting these so such a request degrades to an + * empty checklist rather than a 400. Empty this list once no site carries one. + * + * @return string[] Retired site intents. + */ +function wpcom_launchpad_get_retired_site_intents() { + return array( 'start-writing' ); +} + /** * Clears the site intent for a retired onboarding flow. * @@ -452,9 +453,7 @@ function wpcom_register_default_launchpad_checklists() { * once no site carries one of these values. */ function wpcom_launchpad_clear_retired_site_intents() { - $retired_site_intents = array( 'start-writing' ); - - if ( ! in_array( get_option( 'site_intent' ), $retired_site_intents, true ) ) { + if ( ! in_array( get_option( 'site_intent' ), wpcom_launchpad_get_retired_site_intents(), true ) ) { return; } @@ -1007,24 +1006,34 @@ function wpcom_launchpad_navigator_remove_checklist( $checklist_slug ) { ); } - $current_active_checklist = wpcom_launchpad_get_active_checklist(); - $checklists = $wpcom_launchpad_config['navigator_checklists']; // Find if $checklist_slug is in the checklists array. If it is, remove it. $key = array_search( $checklist_slug, $checklists, true ); if ( $key === false ) { return array( 'updated' => false, - 'new_active_checklist' => $current_active_checklist, + 'new_active_checklist' => wpcom_launchpad_get_active_checklist(), ); } unset( $checklists[ $key ] ); - $new_active_checklist = $current_active_checklist; - if ( $current_active_checklist === $checklist_slug ) { - // get last item on $checklists array, if there is one; otherwise set to null - $new_active_checklist = end( $checklists ) ? end( $checklists ) : null; + // Compare against the raw stored slug, not the getter: a retired active checklist + // reads as null there, which would otherwise hide that it was the one removed. + $stored_active_slug = $wpcom_launchpad_config['active_checklist_slug'] ?? null; + + $new_active_checklist = wpcom_launchpad_get_active_checklist(); + if ( $stored_active_slug === $checklist_slug ) { + // The active checklist was removed. Promote the most recently added one that + // is still registered, skipping any other retired slugs, or clear it. + $registered_checklists = wpcom_launchpad_checklists()->get_all_task_lists(); + $new_active_checklist = null; + foreach ( array_reverse( $checklists ) as $remaining_slug ) { + if ( is_string( $remaining_slug ) && array_key_exists( $remaining_slug, $registered_checklists ) ) { + $new_active_checklist = $remaining_slug; + break; + } + } wpcom_launchpad_set_current_active_checklist( $new_active_checklist ); } @@ -1073,13 +1082,28 @@ function wpcom_launchpad_get_active_checklist() { return null; } - return $wpcom_launchpad_config['active_checklist_slug']; + $active_checklist_slug = $wpcom_launchpad_config['active_checklist_slug']; + + // This repairs persisted state, so it cannot assume a well-formed value: legacy + // or custom code may have stored a non-string, which would fatal below. + if ( ! is_string( $active_checklist_slug ) ) { + return null; + } + + // A retired checklist can linger here from before it was unregistered. Treat it + // as unset, matching how the available list drops unregistered slugs and how the + // setter refuses them, so the Navigator never reports a checklist that is gone. + if ( ! array_key_exists( $active_checklist_slug, wpcom_launchpad_checklists()->get_all_task_lists() ) ) { + return null; + } + + return $active_checklist_slug; } /** * Helper function to set the current active checklist in the navigator context. * - * @param string $checklist_slug The slug of the launchpad task list to mark as active. + * @param string|null $checklist_slug The slug of the launchpad task list to mark as active, or null to clear it. * @return bool Whether the option update succeeded. */ function wpcom_launchpad_set_current_active_checklist( $checklist_slug ) { diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-launchpad-navigator.php b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-launchpad-navigator.php index b39ce8b4b0ec..8025ea2b8738 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-launchpad-navigator.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-launchpad-navigator.php @@ -50,7 +50,7 @@ public function register_routes() { 'remove_checklist_slug' => array( 'description' => 'The slug of the checklist to remove from the active list.', 'type' => 'string', - 'enum' => $this->get_checklist_slug_enums(), + 'enum' => $this->get_readable_checklist_slug_enums(), ), ), ), @@ -61,7 +61,7 @@ public function register_routes() { /** * Validates that the argument sent to the active_checklist_slug parameter is a valid checklist slug or empty. * - * @param string $value The value of the active_checklist_slug parameter. + * @param string|null $value The value of the active_checklist_slug parameter. * @return bool */ public function validate_checklist_slug_param( $value ) { @@ -83,6 +83,21 @@ public function get_checklist_slug_enums() { return array_keys( $checklists ); } + /** + * Returns the checklist slugs a read or removal request may reference. + * + * Retired flow slugs are included so a stale client can still read or remove + * one. Setting a retired slug as active still uses the registered slugs from + * get_checklist_slug_enums(), so it is rejected at the boundary. + * + * @return array Array of checklist slugs. + */ + public function get_readable_checklist_slug_enums() { + return array_values( + array_unique( array_merge( $this->get_checklist_slug_enums(), wpcom_launchpad_get_retired_site_intents() ) ) + ); + } + /** * Updates Launchpad navigator-related options and returns the result * diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-launchpad.php b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-launchpad.php index 6ad250fb1849..bd6f171a9e8f 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-launchpad.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-launchpad.php @@ -43,7 +43,7 @@ public function register_routes() { 'checklist_slug' => array( 'description' => 'Checklist slug', 'type' => 'string', - 'enum' => $this->get_checklist_slug_enums(), + 'enum' => $this->get_readable_checklist_slug_enums(), ), 'launchpad_context' => array( 'description' => 'Screen where Launchpand instance is loaded.', @@ -122,6 +122,21 @@ public function get_checklist_slug_enums() { return array_keys( $checklists ); } + /** + * Returns the checklist slugs a read request may reference. + * + * Retired flow slugs are included so a stale client request degrades to an + * empty checklist rather than a 400. Read-only: mutations use the registered + * slugs from get_checklist_slug_enums() so they cannot act on a retired slug. + * + * @return array Array of checklist slugs. + */ + public function get_readable_checklist_slug_enums() { + return array_values( + array_unique( array_merge( $this->get_checklist_slug_enums(), wpcom_launchpad_get_retired_site_intents() ) ) + ); + } + /** * Returns all registered checklist statuses. * @@ -190,7 +205,7 @@ public function get_data( $request ) { 'launchpad_screen' => get_option( 'launchpad_screen' ), 'checklist_statuses' => get_option( 'launchpad_checklist_tasks_statuses', array() ), 'checklist' => wpcom_get_launchpad_checklist_by_checklist_slug( $checklist_slug, $launchpad_context ), - 'is_enabled' => wpcom_get_launchpad_task_list_is_enabled( $checklist_slug ), + 'is_enabled' => (bool) wpcom_get_launchpad_task_list_is_enabled( $checklist_slug ), 'is_dismissed' => wpcom_launchpad_is_task_list_dismissed( $checklist_slug ), 'is_dismissible' => wpcom_launchpad_is_task_list_dismissible( $checklist_slug ), 'title' => wpcom_get_launchpad_checklist_title_by_checklist_slug( $checklist_slug ), diff --git a/projects/packages/jetpack-mu-wpcom/tests/php/features/launchpad/Launchpad_Retired_Site_Intents_Test.php b/projects/packages/jetpack-mu-wpcom/tests/php/features/launchpad/Launchpad_Retired_Site_Intents_Test.php index 5285f1e4c0c8..792fa9146ba8 100644 --- a/projects/packages/jetpack-mu-wpcom/tests/php/features/launchpad/Launchpad_Retired_Site_Intents_Test.php +++ b/projects/packages/jetpack-mu-wpcom/tests/php/features/launchpad/Launchpad_Retired_Site_Intents_Test.php @@ -5,6 +5,9 @@ * @package automattic/jetpack-mu-wpcom */ +//phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.NotAbsolutePath +require_once \Automattic\Jetpack\Jetpack_Mu_Wpcom::PKG_DIR . 'src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-launchpad-navigator.php'; + use PHPUnit\Framework\Attributes\DataProvider; /** @@ -208,4 +211,91 @@ public function test_cleanup_ignores_the_intent_seen_before_the_blog_switch() { $this->assertSame( 'design-first', get_option( 'site_intent' ), 'the switched-to blog must survive' ); $this->assertSame( 'full', get_option( 'launchpad_screen' ), 'the switched-to blog must survive' ); } + + /** + * A Navigator config left pointing at a retired checklist reports no active + * checklist, rather than one that no longer exists. This can outlive the + * intent cleanup, so it must not depend on `site_intent` still matching. + */ + public function test_retired_active_checklist_slug_reads_as_none() { + wpcom_register_default_launchpad_checklists(); + // Cleared already, to prove the repair does not depend on the intent still matching. + update_option( 'site_intent', '' ); + update_option( + 'wpcom_launchpad_config', + array( + 'active_checklist_slug' => 'start-writing', + 'navigator_checklists' => array( 'start-writing', 'design-first' ), + ) + ); + + $this->assertNull( wpcom_launchpad_get_active_checklist(), 'retired active slug must read as none' ); + } + + /** + * A live active checklist is still reported. + */ + public function test_live_active_checklist_slug_is_reported() { + wpcom_register_default_launchpad_checklists(); + update_option( + 'wpcom_launchpad_config', + array( 'active_checklist_slug' => 'design-first' ) + ); + + $this->assertSame( 'design-first', wpcom_launchpad_get_active_checklist() ); + } + + /** + * Removing a retired active checklist promotes the next registered one, rather + * than leaving the retired slug stored with no active checklist reported. + */ + public function test_removing_retired_active_checklist_promotes_next_registered() { + wpcom_register_default_launchpad_checklists(); + update_option( + 'wpcom_launchpad_config', + array( + 'active_checklist_slug' => 'start-writing', + 'navigator_checklists' => array( 'start-writing', 'design-first' ), + ) + ); + + $result = wpcom_launchpad_navigator_remove_checklist( 'start-writing' ); + + $this->assertSame( 'design-first', $result['new_active_checklist'], 'the next registered checklist must be promoted' ); + $this->assertSame( 'design-first', wpcom_launchpad_get_active_checklist(), 'the promotion must be persisted' ); + } + + /** + * A non-string active checklist slug from malformed legacy state reads as none + * rather than fataling on the registered-checklist lookup. + */ + public function test_malformed_active_checklist_slug_reads_as_none() { + wpcom_register_default_launchpad_checklists(); + update_option( + 'wpcom_launchpad_config', + array( 'active_checklist_slug' => array( 'unexpected' ) ) + ); + + $this->assertNull( wpcom_launchpad_get_active_checklist() ); + } + + /** + * Retired slugs are accepted for reads and removal, but not for setting the + * active checklist, so a retired flow cannot be re-selected. + */ + public function test_navigator_enums_separate_reads_from_mutations() { + wpcom_register_default_launchpad_checklists(); + $navigator = new WPCOM_REST_API_V2_Endpoint_Launchpad_Navigator(); + + $registered = $navigator->get_checklist_slug_enums(); + $this->assertContains( 'design-first', $registered ); + $this->assertNotContains( 'start-writing', $registered, 'mutations must not accept a retired slug' ); + + $readable = $navigator->get_readable_checklist_slug_enums(); + $this->assertContains( 'start-writing', $readable, 'reads and removal must accept a retired slug' ); + + $this->assertFalse( $navigator->validate_checklist_slug_param( 'start-writing' ), 'setting a retired slug active must be rejected' ); + $this->assertTrue( $navigator->validate_checklist_slug_param( 'design-first' ) ); + $this->assertTrue( $navigator->validate_checklist_slug_param( null ), 'clearing the active checklist must stay allowed' ); + } } diff --git a/projects/packages/jetpack-mu-wpcom/tests/php/features/wpcom-endpoints/WPCOM_REST_API_V2_Endpoint_Launchpad_Test.php b/projects/packages/jetpack-mu-wpcom/tests/php/features/wpcom-endpoints/WPCOM_REST_API_V2_Endpoint_Launchpad_Test.php index 9705aa858578..ed42feb67a73 100644 --- a/projects/packages/jetpack-mu-wpcom/tests/php/features/wpcom-endpoints/WPCOM_REST_API_V2_Endpoint_Launchpad_Test.php +++ b/projects/packages/jetpack-mu-wpcom/tests/php/features/wpcom-endpoints/WPCOM_REST_API_V2_Endpoint_Launchpad_Test.php @@ -72,6 +72,47 @@ public function test_get_data() { $this->assertFalse( $result->get_data()['is_dismissed'] ); } + /** + * A retired flow slug has no checklist, but clients still pass it explicitly + * from stale site options. It must degrade to a complete, well-typed empty + * response rather than a 400, so a strict client schema still decodes it. + */ + public function test_get_data_accepts_retired_checklist_slug() { + wp_set_current_user( $this->admin_id ); + + $request = new WP_REST_Request( Requests::GET, '/wpcom/v2/launchpad' ); + $request->set_param( 'checklist_slug', 'start-writing' ); + $result = rest_do_request( $request ); + $data = $result->get_data(); + + $this->assertEquals( 200, $result->get_status() ); + $this->assertSame( array(), $data['checklist'] ); + // is_enabled is a required boolean in the client type, never null. + $this->assertFalse( $data['is_enabled'] ); + $this->assertFalse( $data['is_dismissed'] ); + $this->assertFalse( $data['is_dismissible'] ); + } + + /** + * Retired slugs are read-only compatibility. Dismissing one is a mutation and + * must be rejected, so no dismissal is persisted for a checklist that is gone. + */ + public function test_dismissing_a_retired_checklist_slug_is_rejected() { + wp_set_current_user( $this->admin_id ); + + $result = $this->call_launchpad_api( + Requests::POST, + array( + 'is_checklist_dismissed' => array( + 'slug' => 'start-writing', + 'is_dismissed' => true, + ), + ) + ); + + $this->assertEquals( 400, $result->get_status() ); + } + /** * Test can_access. */ @@ -410,7 +451,7 @@ public function test_get_tasklist_using_goals( $site_goals, $enable_checklist_fo update_option( 'site_goals', $site_goals ); $data = array( - 'checklist_slug' => 'start-writing', // This should get ignored, due to the use_goals flag. + 'checklist_slug' => 'design-first', // This should get ignored, due to the use_goals flag. 'launchpad_context' => 'customer-home', 'use_goals' => true, 'enable_checklist_for_goals' => $enable_checklist_for_goals,