From 3cdf45f69cfd6cc23fafd4933017112508d4039b Mon Sep 17 00:00:00 2001 From: Vedanshu Jain Date: Wed, 18 Sep 2024 12:58:38 +0530 Subject: [PATCH 01/12] Modify API response tests to assert that expected key values are present (#51465) Modify API response tests to assert that expected key values are present. This differs from earlier assertion which checks for exact equality. However the earlier assertion was brittle and would fail when a new property is added, for example in https://core.trac.wordpress.org/ticket/61739. With this change, any addition to schema will not breaking existing API tests. --- .../changelog/fix-unit-test-trac-61739 | 5 + .../Tests/Version2/product-reviews.php | 63 +++--- .../rest-api/Tests/Version2/settings.php | 99 +++++---- .../Tests/Version2/shipping-methods.php | 45 ++-- .../Tests/Version2/shipping-zones.php | 206 +++++++++-------- .../Tests/Version3/product-reviews.php | 63 +++--- .../rest-api/Tests/Version3/settings.php | 102 +++++---- .../Tests/Version3/shipping-methods.php | 45 ++-- .../Tests/Version3/shipping-zones.php | 207 +++++++++--------- 9 files changed, 476 insertions(+), 359 deletions(-) create mode 100644 plugins/woocommerce/changelog/fix-unit-test-trac-61739 diff --git a/plugins/woocommerce/changelog/fix-unit-test-trac-61739 b/plugins/woocommerce/changelog/fix-unit-test-trac-61739 new file mode 100644 index 00000000000..45cd4544e59 --- /dev/null +++ b/plugins/woocommerce/changelog/fix-unit-test-trac-61739 @@ -0,0 +1,5 @@ +Significance: patch +Type: dev +Comment: Update unit test to account for WordPress nightly change. See core trac ticket 61739 + + diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/product-reviews.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/product-reviews.php index df53aeece57..a68ad775f87 100644 --- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/product-reviews.php +++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/product-reviews.php @@ -1,4 +1,7 @@ assertEquals( 200, $response->get_status() ); $this->assertEquals( 10, count( $product_reviews ) ); - $this->assertContains( - array( - 'id' => $review_id, - 'date_created' => $product_reviews[0]['date_created'], - 'date_created_gmt' => $product_reviews[0]['date_created_gmt'], - 'product_id' => $product->get_id(), - 'product_name' => $product->get_name(), - 'product_permalink' => $product->get_permalink(), - 'status' => 'approved', - 'reviewer' => 'admin', - 'reviewer_email' => 'woo@woo.local', - 'review' => "

Review content here

\n", - 'rating' => 0, - 'verified' => false, - 'reviewer_avatar_urls' => $product_reviews[0]['reviewer_avatar_urls'], - '_links' => array( - 'self' => array( - array( - 'href' => rest_url( '/wc/v3/products/reviews/' . $review_id ), + $this->assertEmpty( + ArrayUtil::deep_assoc_array_diff( + array( + 'id' => $review_id, + 'date_created' => $product_reviews[0]['date_created'], + 'date_created_gmt' => $product_reviews[0]['date_created_gmt'], + 'product_id' => $product->get_id(), + 'product_name' => $product->get_name(), + 'product_permalink' => $product->get_permalink(), + 'status' => 'approved', + 'reviewer' => 'admin', + 'reviewer_email' => 'woo@woo.local', + 'review' => "

Review content here

\n", + 'rating' => 0, + 'verified' => false, + 'reviewer_avatar_urls' => $product_reviews[0]['reviewer_avatar_urls'], + '_links' => array( + 'self' => array( + array( + 'href' => rest_url( '/wc/v3/products/reviews/' . $review_id ), + ), ), - ), - 'collection' => array( - array( - 'href' => rest_url( '/wc/v3/products/reviews' ), + 'collection' => array( + array( + 'href' => rest_url( '/wc/v3/products/reviews' ), + ), ), - ), - 'up' => array( - array( - 'href' => rest_url( '/wc/v3/products/' . $product->get_id() ), + 'up' => array( + array( + 'href' => rest_url( '/wc/v3/products/' . $product->get_id() ), + ), ), ), ), - ), - $product_reviews + $product_reviews[0] + ) ); } diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/settings.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/settings.php index 32a96fb95dc..f185a811097 100644 --- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/settings.php +++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/settings.php @@ -6,6 +6,7 @@ * @since 3.0.0 */ +use Automattic\WooCommerce\Utilities\ArrayUtil; use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts; /** @@ -482,29 +483,39 @@ class Settings_V2 extends WC_REST_Unit_Test_Case { $response = $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/v2/settings/products' ) ); $data = $response->get_data(); $this->assertTrue( is_array( $data ) ); - $this->assertContains( - array( - 'id' => 'woocommerce_downloads_require_login', - 'label' => 'Access restriction', - 'description' => 'Downloads require login', - 'type' => 'checkbox', - 'default' => 'no', - 'tip' => 'This setting does not apply to guest purchases.', - 'value' => 'no', - '_links' => array( - 'self' => array( - array( - 'href' => rest_url( '/wc/v2/settings/products/woocommerce_downloads_require_login' ), + $data_download_required_login = null; + foreach ( $data as $setting ) { + if ( 'woocommerce_downloads_require_login' === $setting['id'] ) { + $data_download_required_login = $setting; + break; + } + } + $this->assertNotEmpty( $data_download_required_login ); + $this->assertEmpty( + ArrayUtil::deep_assoc_array_diff( + array( + 'id' => 'woocommerce_downloads_require_login', + 'label' => 'Access restriction', + 'description' => 'Downloads require login', + 'type' => 'checkbox', + 'default' => 'no', + 'tip' => 'This setting does not apply to guest purchases.', + 'value' => 'no', + '_links' => array( + 'self' => array( + array( + 'href' => rest_url( '/wc/v2/settings/products/woocommerce_downloads_require_login' ), + ), ), - ), - 'collection' => array( - array( - 'href' => rest_url( '/wc/v2/settings/products' ), + 'collection' => array( + array( + 'href' => rest_url( '/wc/v2/settings/products' ), + ), ), ), ), - ), - $data + $data_download_required_login + ) ); // test get single. @@ -540,29 +551,41 @@ class Settings_V2 extends WC_REST_Unit_Test_Case { $this->assertEquals( 200, $response->get_status() ); - $this->assertContains( - array( - 'id' => 'recipient', - 'label' => 'Recipient(s)', - 'description' => 'Enter recipients (comma separated) for this email. Defaults to admin@example.org.', - 'type' => 'text', - 'default' => '', - 'tip' => 'Enter recipients (comma separated) for this email. Defaults to admin@example.org.', - 'value' => '', - '_links' => array( - 'self' => array( - array( - 'href' => rest_url( '/wc/v2/settings/email_new_order/recipient' ), + $recipient_setting = null; + foreach ( $settings as $setting ) { + if ( 'recipient' === $setting['id'] ) { + $recipient_setting = $setting; + break; + } + } + + $this->assertNotEmpty( $recipient_setting ); + + $this->assertEmpty( + ArrayUtil::deep_assoc_array_diff( + array( + 'id' => 'recipient', + 'label' => 'Recipient(s)', + 'description' => 'Enter recipients (comma separated) for this email. Defaults to admin@example.org.', + 'type' => 'text', + 'default' => '', + 'tip' => 'Enter recipients (comma separated) for this email. Defaults to admin@example.org.', + 'value' => '', + '_links' => array( + 'self' => array( + array( + 'href' => rest_url( '/wc/v2/settings/email_new_order/recipient' ), + ), ), - ), - 'collection' => array( - array( - 'href' => rest_url( '/wc/v2/settings/email_new_order' ), + 'collection' => array( + array( + 'href' => rest_url( '/wc/v2/settings/email_new_order' ), + ), ), ), ), - ), - $settings + $recipient_setting + ) ); // test get single. diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/shipping-methods.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/shipping-methods.php index 3f13ecb2ee1..d27efd5b67b 100644 --- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/shipping-methods.php +++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/shipping-methods.php @@ -1,4 +1,7 @@ get_data(); $this->assertEquals( 200, $response->get_status() ); - $this->assertContains( - array( - 'id' => 'free_shipping', - 'title' => 'Free shipping', - 'description' => 'Free shipping is a special method which can be triggered with coupons and minimum spends.', - '_links' => array( - 'self' => array( - array( - 'href' => rest_url( '/wc/v2/shipping_methods/free_shipping' ), + + $free_shipping_method = null; + foreach ( $methods as $method ) { + if ( 'free_shipping' === $method['id'] ) { + $free_shipping_method = $method; + break; + } + } + $this->assertNotEmpty( $free_shipping_method ); + + $this->assertEmpty( + ArrayUtil::deep_assoc_array_diff( + array( + 'id' => 'free_shipping', + 'title' => 'Free shipping', + 'description' => 'Free shipping is a special method which can be triggered with coupons and minimum spends.', + '_links' => array( + 'self' => array( + array( + 'href' => rest_url( '/wc/v2/shipping_methods/free_shipping' ), + ), ), - ), - 'collection' => array( - array( - 'href' => rest_url( '/wc/v2/shipping_methods' ), + 'collection' => array( + array( + 'href' => rest_url( '/wc/v2/shipping_methods' ), + ), ), ), ), - ), - $methods + $free_shipping_method + ) ); } diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/shipping-zones.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/shipping-zones.php index bc020791d02..1e15bb91e02 100644 --- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/shipping-zones.php +++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/shipping-zones.php @@ -1,5 +1,7 @@ assertEquals( 200, $response->get_status() ); $this->assertEquals( count( $data ), 1 ); - $this->assertContains( - array( - 'id' => $data[0]['id'], - 'name' => 'Locations not covered by your other zones', - 'order' => 0, - '_links' => array( - 'self' => array( - array( - 'href' => rest_url( '/wc/v2/shipping/zones/' . $data[0]['id'] ), + $this->assertEmpty( + ArrayUtil::deep_assoc_array_diff( + array( + 'id' => $data[0]['id'], + 'name' => 'Locations not covered by your other zones', + 'order' => 0, + '_links' => array( + 'self' => array( + array( + 'href' => rest_url( '/wc/v2/shipping/zones/' . $data[0]['id'] ), + ), ), - ), - 'collection' => array( - array( - 'href' => rest_url( '/wc/v2/shipping/zones' ), + 'collection' => array( + array( + 'href' => rest_url( '/wc/v2/shipping/zones' ), + ), ), - ), - 'describedby' => array( - array( - 'href' => rest_url( '/wc/v2/shipping/zones/' . $data[0]['id'] . '/locations' ), + 'describedby' => array( + array( + 'href' => rest_url( '/wc/v2/shipping/zones/' . $data[0]['id'] . '/locations' ), + ), ), ), ), - ), - $data + $data[0] + ) ); // Create a zone and make sure it's in the response @@ -108,30 +112,32 @@ class WC_Tests_API_Shipping_Zones_V2 extends WC_REST_Unit_Test_Case { $this->assertEquals( 200, $response->get_status() ); $this->assertEquals( count( $data ), 2 ); - $this->assertContains( - array( - 'id' => $data[1]['id'], - 'name' => 'Zone 1', - 'order' => 0, - '_links' => array( - 'self' => array( - array( - 'href' => rest_url( '/wc/v2/shipping/zones/' . $data[1]['id'] ), + $this->assertEmpty( + ArrayUtil::deep_assoc_array_diff( + array( + 'id' => $data[1]['id'], + 'name' => 'Zone 1', + 'order' => 0, + '_links' => array( + 'self' => array( + array( + 'href' => rest_url( '/wc/v2/shipping/zones/' . $data[1]['id'] ), + ), ), - ), - 'collection' => array( - array( - 'href' => rest_url( '/wc/v2/shipping/zones' ), + 'collection' => array( + array( + 'href' => rest_url( '/wc/v2/shipping/zones' ), + ), ), - ), - 'describedby' => array( - array( - 'href' => rest_url( '/wc/v2/shipping/zones/' . $data[1]['id'] . '/locations' ), + 'describedby' => array( + array( + 'href' => rest_url( '/wc/v2/shipping/zones/' . $data[1]['id'] . '/locations' ), + ), ), ), ), - ), - $data + $data[1] + ) ); } @@ -195,30 +201,32 @@ class WC_Tests_API_Shipping_Zones_V2 extends WC_REST_Unit_Test_Case { $data = $response->get_data(); $this->assertEquals( 201, $response->get_status() ); - $this->assertEquals( - array( - 'id' => $data['id'], - 'name' => 'Test Zone', - 'order' => 1, - '_links' => array( - 'self' => array( - array( - 'href' => rest_url( '/wc/v2/shipping/zones/' . $data['id'] ), + $this->assertEmpty( + ArrayUtil::deep_assoc_array_diff( + array( + 'id' => $data['id'], + 'name' => 'Test Zone', + 'order' => 1, + '_links' => array( + 'self' => array( + array( + 'href' => rest_url( '/wc/v2/shipping/zones/' . $data['id'] ), + ), ), - ), - 'collection' => array( - array( - 'href' => rest_url( '/wc/v2/shipping/zones' ), + 'collection' => array( + array( + 'href' => rest_url( '/wc/v2/shipping/zones' ), + ), ), - ), - 'describedby' => array( - array( - 'href' => rest_url( '/wc/v2/shipping/zones/' . $data['id'] . '/locations' ), + 'describedby' => array( + array( + 'href' => rest_url( '/wc/v2/shipping/zones/' . $data['id'] . '/locations' ), + ), ), ), ), - ), - $data + $data + ) ); } @@ -260,30 +268,32 @@ class WC_Tests_API_Shipping_Zones_V2 extends WC_REST_Unit_Test_Case { $data = $response->get_data(); $this->assertEquals( 200, $response->get_status() ); - $this->assertEquals( - array( - 'id' => $zone->get_id(), - 'name' => 'Zone Test', - 'order' => 2, - '_links' => array( - 'self' => array( - array( - 'href' => rest_url( '/wc/v2/shipping/zones/' . $zone->get_id() ), + $this->assertEmpty( + ArrayUtil::deep_assoc_array_diff( + array( + 'id' => $zone->get_id(), + 'name' => 'Zone Test', + 'order' => 2, + '_links' => array( + 'self' => array( + array( + 'href' => rest_url( '/wc/v2/shipping/zones/' . $zone->get_id() ), + ), ), - ), - 'collection' => array( - array( - 'href' => rest_url( '/wc/v2/shipping/zones' ), + 'collection' => array( + array( + 'href' => rest_url( '/wc/v2/shipping/zones' ), + ), ), - ), - 'describedby' => array( - array( - 'href' => rest_url( '/wc/v2/shipping/zones/' . $zone->get_id() . '/locations' ), + 'describedby' => array( + array( + 'href' => rest_url( '/wc/v2/shipping/zones/' . $zone->get_id() . '/locations' ), + ), ), ), ), - ), - $data + $data + ) ); } @@ -359,30 +369,32 @@ class WC_Tests_API_Shipping_Zones_V2 extends WC_REST_Unit_Test_Case { $data = $response->get_data(); $this->assertEquals( 200, $response->get_status() ); - $this->assertEquals( - array( - 'id' => $zone->get_id(), - 'name' => 'Test Zone', - 'order' => 0, - '_links' => array( - 'self' => array( - array( - 'href' => rest_url( '/wc/v2/shipping/zones/' . $zone->get_id() ), + $this->assertEmpty( + ArrayUtil::deep_assoc_array_diff( + array( + 'id' => $zone->get_id(), + 'name' => 'Test Zone', + 'order' => 0, + '_links' => array( + 'self' => array( + array( + 'href' => rest_url( '/wc/v2/shipping/zones/' . $zone->get_id() ), + ), ), - ), - 'collection' => array( - array( - 'href' => rest_url( '/wc/v2/shipping/zones' ), + 'collection' => array( + array( + 'href' => rest_url( '/wc/v2/shipping/zones' ), + ), ), - ), - 'describedby' => array( - array( - 'href' => rest_url( '/wc/v2/shipping/zones/' . $zone->get_id() . '/locations' ), + 'describedby' => array( + array( + 'href' => rest_url( '/wc/v2/shipping/zones/' . $zone->get_id() . '/locations' ), + ), ), ), ), - ), - $data + $data + ) ); } @@ -624,13 +636,13 @@ class WC_Tests_API_Shipping_Zones_V2 extends WC_REST_Unit_Test_Case { $this->assertEquals( 200, $response->get_status() ); $this->assertEquals( count( $data ), 1 ); - $this->assertContains( $expected, $data ); + $this->assertEmpty( ArrayUtil::deep_assoc_array_diff( $expected, $data[0] ) ); $response = $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/v2/shipping/zones/' . $zone->get_id() . '/methods/' . $instance_id ) ); $data = $response->get_data(); $this->assertEquals( 200, $response->get_status() ); - $this->assertEquals( $expected, $data ); + $this->assertEmpty( ArrayUtil::deep_assoc_array_diff( $expected, $data ) ); } /** diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/product-reviews.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/product-reviews.php index 11aa94c16b7..b655ffb538a 100644 --- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/product-reviews.php +++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/product-reviews.php @@ -1,4 +1,7 @@ assertEquals( 200, $response->get_status() ); $this->assertEquals( 10, count( $product_reviews ) ); - $this->assertContains( - array( - 'id' => $review_id, - 'date_created' => $product_reviews[0]['date_created'], - 'date_created_gmt' => $product_reviews[0]['date_created_gmt'], - 'product_id' => $product->get_id(), - 'product_name' => $product->get_name(), - 'product_permalink' => $product->get_permalink(), - 'status' => 'approved', - 'reviewer' => 'admin', - 'reviewer_email' => 'woo@woo.local', - 'review' => "

Review content here

\n", - 'rating' => 0, - 'verified' => false, - 'reviewer_avatar_urls' => $product_reviews[0]['reviewer_avatar_urls'], - '_links' => array( - 'self' => array( - array( - 'href' => rest_url( '/wc/v3/products/reviews/' . $review_id ), + $this->assertEmpty( + ArrayUtil::deep_assoc_array_diff( + array( + 'id' => $review_id, + 'date_created' => $product_reviews[0]['date_created'], + 'date_created_gmt' => $product_reviews[0]['date_created_gmt'], + 'product_id' => $product->get_id(), + 'product_name' => $product->get_name(), + 'product_permalink' => $product->get_permalink(), + 'status' => 'approved', + 'reviewer' => 'admin', + 'reviewer_email' => 'woo@woo.local', + 'review' => "

Review content here

\n", + 'rating' => 0, + 'verified' => false, + 'reviewer_avatar_urls' => $product_reviews[0]['reviewer_avatar_urls'], + '_links' => array( + 'self' => array( + array( + 'href' => rest_url( '/wc/v3/products/reviews/' . $review_id ), + ), ), - ), - 'collection' => array( - array( - 'href' => rest_url( '/wc/v3/products/reviews' ), + 'collection' => array( + array( + 'href' => rest_url( '/wc/v3/products/reviews' ), + ), ), - ), - 'up' => array( - array( - 'href' => rest_url( '/wc/v3/products/' . $product->get_id() ), + 'up' => array( + array( + 'href' => rest_url( '/wc/v3/products/' . $product->get_id() ), + ), ), ), ), - ), - $product_reviews + $product_reviews[0] + ) ); } diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/settings.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/settings.php index d8890569eff..d10062ec2ee 100644 --- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/settings.php +++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/settings.php @@ -6,6 +6,7 @@ * @since 3.5.0 */ +use Automattic\WooCommerce\Utilities\ArrayUtil; use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts; /** @@ -481,29 +482,42 @@ class Settings extends WC_REST_Unit_Test_Case { $response = $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/v3/settings/products' ) ); $data = $response->get_data(); $this->assertTrue( is_array( $data ) ); - $this->assertContains( - array( - 'id' => 'woocommerce_downloads_require_login', - 'label' => 'Access restriction', - 'description' => 'Downloads require login', - 'type' => 'checkbox', - 'default' => 'no', - 'tip' => 'This setting does not apply to guest purchases.', - 'value' => 'no', - '_links' => array( - 'self' => array( - array( - 'href' => rest_url( '/wc/v3/settings/products/woocommerce_downloads_require_login' ), + + $setting_downloads_required = null; + foreach ( $data as $setting ) { + if ( 'woocommerce_downloads_require_login' === $setting['id'] ) { + $setting_downloads_required = $setting; + break; + } + } + + $this->assertNotEmpty( $setting_downloads_required ); + + $this->assertEmpty( + ArrayUtil::deep_assoc_array_diff( + array( + 'id' => 'woocommerce_downloads_require_login', + 'label' => 'Access restriction', + 'description' => 'Downloads require login', + 'type' => 'checkbox', + 'default' => 'no', + 'tip' => 'This setting does not apply to guest purchases.', + 'value' => 'no', + '_links' => array( + 'self' => array( + array( + 'href' => rest_url( '/wc/v3/settings/products/woocommerce_downloads_require_login' ), + ), ), - ), - 'collection' => array( - array( - 'href' => rest_url( '/wc/v3/settings/products' ), + 'collection' => array( + array( + 'href' => rest_url( '/wc/v3/settings/products' ), + ), ), ), ), - ), - $data + $setting_downloads_required + ) ); // test get single. @@ -539,29 +553,41 @@ class Settings extends WC_REST_Unit_Test_Case { $this->assertEquals( 200, $response->get_status() ); - $this->assertContains( - array( - 'id' => 'recipient', - 'label' => 'Recipient(s)', - 'description' => 'Enter recipients (comma separated) for this email. Defaults to admin@example.org.', - 'type' => 'text', - 'default' => '', - 'tip' => 'Enter recipients (comma separated) for this email. Defaults to admin@example.org.', - 'value' => '', - '_links' => array( - 'self' => array( - array( - 'href' => rest_url( '/wc/v3/settings/email_new_order/recipient' ), + $recipient_setting = null; + foreach ( $settings as $setting ) { + if ( 'recipient' === $setting['id'] ) { + $recipient_setting = $setting; + break; + } + } + + $this->assertNotEmpty( $recipient_setting ); + + $this->assertEmpty( + ArrayUtil::deep_assoc_array_diff( + array( + 'id' => 'recipient', + 'label' => 'Recipient(s)', + 'description' => 'Enter recipients (comma separated) for this email. Defaults to admin@example.org.', + 'type' => 'text', + 'default' => '', + 'tip' => 'Enter recipients (comma separated) for this email. Defaults to admin@example.org.', + 'value' => '', + '_links' => array( + 'self' => array( + array( + 'href' => rest_url( '/wc/v3/settings/email_new_order/recipient' ), + ), ), - ), - 'collection' => array( - array( - 'href' => rest_url( '/wc/v3/settings/email_new_order' ), + 'collection' => array( + array( + 'href' => rest_url( '/wc/v3/settings/email_new_order' ), + ), ), ), ), - ), - $settings + $recipient_setting + ) ); // test get single. diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/shipping-methods.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/shipping-methods.php index 31dc36c1b14..05d38ad0517 100644 --- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/shipping-methods.php +++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/shipping-methods.php @@ -1,4 +1,7 @@ get_data(); $this->assertEquals( 200, $response->get_status() ); - $this->assertContains( - array( - 'id' => 'free_shipping', - 'title' => 'Free shipping', - 'description' => 'Free shipping is a special method which can be triggered with coupons and minimum spends.', - '_links' => array( - 'self' => array( - array( - 'href' => rest_url( '/wc/v3/shipping_methods/free_shipping' ), + + $free_shipping = null; + foreach ( $methods as $method ) { + if ( 'free_shipping' === $method['id'] ) { + $free_shipping = $method; + break; + } + } + $this->assertNotEmpty( $free_shipping ); + + $this->assertEmpty( + ArrayUtil::deep_assoc_array_diff( + array( + 'id' => 'free_shipping', + 'title' => 'Free shipping', + 'description' => 'Free shipping is a special method which can be triggered with coupons and minimum spends.', + '_links' => array( + 'self' => array( + array( + 'href' => rest_url( '/wc/v3/shipping_methods/free_shipping' ), + ), ), - ), - 'collection' => array( - array( - 'href' => rest_url( '/wc/v3/shipping_methods' ), + 'collection' => array( + array( + 'href' => rest_url( '/wc/v3/shipping_methods' ), + ), ), ), ), - ), - $methods + $free_shipping + ) ); } diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/shipping-zones.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/shipping-zones.php index 1dd58034653..3c49902d989 100644 --- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/shipping-zones.php +++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/shipping-zones.php @@ -1,5 +1,7 @@ assertEquals( 200, $response->get_status() ); $this->assertEquals( count( $data ), 1 ); - $this->assertContains( - array( - 'id' => $data[0]['id'], - 'name' => 'Locations not covered by your other zones', - 'order' => 0, - '_links' => array( - 'self' => array( - array( - 'href' => rest_url( '/wc/v3/shipping/zones/' . $data[0]['id'] ), + $this->assertEmpty( + ArrayUtil::deep_assoc_array_diff( + array( + 'id' => $data[0]['id'], + 'name' => 'Locations not covered by your other zones', + 'order' => 0, + '_links' => array( + 'self' => array( + array( + 'href' => rest_url( '/wc/v3/shipping/zones/' . $data[0]['id'] ), + ), ), - ), - 'collection' => array( - array( - 'href' => rest_url( '/wc/v3/shipping/zones' ), + 'collection' => array( + array( + 'href' => rest_url( '/wc/v3/shipping/zones' ), + ), ), - ), - 'describedby' => array( - array( - 'href' => rest_url( '/wc/v3/shipping/zones/' . $data[0]['id'] . '/locations' ), + 'describedby' => array( + array( + 'href' => rest_url( '/wc/v3/shipping/zones/' . $data[0]['id'] . '/locations' ), + ), ), ), ), - ), - $data + $data[0] + ) ); // Create a zone and make sure it's in the response @@ -111,30 +115,32 @@ class WC_Tests_API_Shipping_Zones extends WC_REST_Unit_Test_Case { $this->assertEquals( 200, $response->get_status() ); $this->assertEquals( count( $data ), 2 ); - $this->assertContains( - array( - 'id' => $data[1]['id'], - 'name' => 'Zone 1', - 'order' => 0, - '_links' => array( - 'self' => array( - array( - 'href' => rest_url( '/wc/v3/shipping/zones/' . $data[1]['id'] ), + $this->assertEmpty( + ArrayUtil::deep_assoc_array_diff( + array( + 'id' => $data[1]['id'], + 'name' => 'Zone 1', + 'order' => 0, + '_links' => array( + 'self' => array( + array( + 'href' => rest_url( '/wc/v3/shipping/zones/' . $data[1]['id'] ), + ), ), - ), - 'collection' => array( - array( - 'href' => rest_url( '/wc/v3/shipping/zones' ), + 'collection' => array( + array( + 'href' => rest_url( '/wc/v3/shipping/zones' ), + ), ), - ), - 'describedby' => array( - array( - 'href' => rest_url( '/wc/v3/shipping/zones/' . $data[1]['id'] . '/locations' ), + 'describedby' => array( + array( + 'href' => rest_url( '/wc/v3/shipping/zones/' . $data[1]['id'] . '/locations' ), + ), ), ), ), - ), - $data + $data[1] + ) ); } @@ -202,30 +208,32 @@ class WC_Tests_API_Shipping_Zones extends WC_REST_Unit_Test_Case { $data = $response->get_data(); $this->assertEquals( 201, $response->get_status() ); - $this->assertEquals( - array( - 'id' => $data['id'], - 'name' => 'Test Zone', - 'order' => 1, - '_links' => array( - 'self' => array( - array( - 'href' => rest_url( '/wc/v3/shipping/zones/' . $data['id'] ), + $this->assertEmpty( + ArrayUtil::deep_assoc_array_diff( + array( + 'id' => $data['id'], + 'name' => 'Test Zone', + 'order' => 1, + '_links' => array( + 'self' => array( + array( + 'href' => rest_url( '/wc/v3/shipping/zones/' . $data['id'] ), + ), ), - ), - 'collection' => array( - array( - 'href' => rest_url( '/wc/v3/shipping/zones' ), + 'collection' => array( + array( + 'href' => rest_url( '/wc/v3/shipping/zones' ), + ), ), - ), - 'describedby' => array( - array( - 'href' => rest_url( '/wc/v3/shipping/zones/' . $data['id'] . '/locations' ), + 'describedby' => array( + array( + 'href' => rest_url( '/wc/v3/shipping/zones/' . $data['id'] . '/locations' ), + ), ), ), ), - ), - $data + $data + ) ); } @@ -269,30 +277,32 @@ class WC_Tests_API_Shipping_Zones extends WC_REST_Unit_Test_Case { $data = $response->get_data(); $this->assertEquals( 200, $response->get_status() ); - $this->assertEquals( - array( - 'id' => $zone->get_id(), - 'name' => 'Zone Test', - 'order' => 2, - '_links' => array( - 'self' => array( - array( - 'href' => rest_url( '/wc/v3/shipping/zones/' . $zone->get_id() ), + $this->assertEmpty( + ArrayUtil::deep_assoc_array_diff( + array( + 'id' => $zone->get_id(), + 'name' => 'Zone Test', + 'order' => 2, + '_links' => array( + 'self' => array( + array( + 'href' => rest_url( '/wc/v3/shipping/zones/' . $zone->get_id() ), + ), ), - ), - 'collection' => array( - array( - 'href' => rest_url( '/wc/v3/shipping/zones' ), + 'collection' => array( + array( + 'href' => rest_url( '/wc/v3/shipping/zones' ), + ), ), - ), - 'describedby' => array( - array( - 'href' => rest_url( '/wc/v3/shipping/zones/' . $zone->get_id() . '/locations' ), + 'describedby' => array( + array( + 'href' => rest_url( '/wc/v3/shipping/zones/' . $zone->get_id() . '/locations' ), + ), ), ), ), - ), - $data + $data + ) ); } @@ -373,30 +383,32 @@ class WC_Tests_API_Shipping_Zones extends WC_REST_Unit_Test_Case { $data = $response->get_data(); $this->assertEquals( 200, $response->get_status() ); - $this->assertEquals( - array( - 'id' => $zone->get_id(), - 'name' => 'Test Zone', - 'order' => 0, - '_links' => array( - 'self' => array( - array( - 'href' => rest_url( '/wc/v3/shipping/zones/' . $zone->get_id() ), + $this->assertEmpty( + ArrayUtil::deep_assoc_array_diff( + array( + 'id' => $zone->get_id(), + 'name' => 'Test Zone', + 'order' => 0, + '_links' => array( + 'self' => array( + array( + 'href' => rest_url( '/wc/v3/shipping/zones/' . $zone->get_id() ), + ), ), - ), - 'collection' => array( - array( - 'href' => rest_url( '/wc/v3/shipping/zones' ), + 'collection' => array( + array( + 'href' => rest_url( '/wc/v3/shipping/zones' ), + ), ), - ), - 'describedby' => array( - array( - 'href' => rest_url( '/wc/v3/shipping/zones/' . $zone->get_id() . '/locations' ), + 'describedby' => array( + array( + 'href' => rest_url( '/wc/v3/shipping/zones/' . $zone->get_id() . '/locations' ), + ), ), ), ), - ), - $data + $data + ) ); } @@ -644,13 +656,12 @@ class WC_Tests_API_Shipping_Zones extends WC_REST_Unit_Test_Case { $this->assertEquals( 200, $response->get_status() ); $this->assertEquals( count( $data ), 1 ); - $this->assertContains( $expected, $data ); - + $this->assertEmpty( ArrayUtil::deep_assoc_array_diff( $expected, $data[0] ) ); $response = $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/v3/shipping/zones/' . $zone->get_id() . '/methods/' . $instance_id ) ); $data = $response->get_data(); $this->assertEquals( 200, $response->get_status() ); - $this->assertEquals( $expected, $data ); + $this->assertEmpty( ArrayUtil::deep_assoc_array_diff( $expected, $data ) ); } /** From f529d927a2a15cfe9bc6730eade68d74c0764b17 Mon Sep 17 00:00:00 2001 From: Maikel Perez Date: Wed, 18 Sep 2024 04:59:14 -0300 Subject: [PATCH 02/12] CYS: Fix Looker dashboard data (Track & display average loading times) (#51461) * Track customize_your_store_assembler_hub_editor_loaded event to measure CYS loading time * Add changelog file --- .../client/customize-store/assembler-hub/editor.tsx | 2 ++ plugins/woocommerce/changelog/add-50832-loading-time | 4 ++++ 2 files changed, 6 insertions(+) create mode 100644 plugins/woocommerce/changelog/add-50832-loading-time diff --git a/plugins/woocommerce-admin/client/customize-store/assembler-hub/editor.tsx b/plugins/woocommerce-admin/client/customize-store/assembler-hub/editor.tsx index c2f8c454009..93920c3f71f 100644 --- a/plugins/woocommerce-admin/client/customize-store/assembler-hub/editor.tsx +++ b/plugins/woocommerce-admin/client/customize-store/assembler-hub/editor.tsx @@ -23,6 +23,7 @@ import { GlobalStylesRenderer } from '@wordpress/edit-site/build-module/componen /** * Internal dependencies */ +import { trackEvent } from '../tracking'; import { editorIsLoaded } from '../utils'; import { BlockEditorContainer } from './block-editor-container'; @@ -63,6 +64,7 @@ export const Editor = ( { isLoading }: { isLoading: boolean } ) => { useEffect( () => { if ( ! isLoading ) { editorIsLoaded(); + trackEvent( 'customize_your_store_assembler_hub_editor_loaded' ); } }, [ isLoading ] ); diff --git a/plugins/woocommerce/changelog/add-50832-loading-time b/plugins/woocommerce/changelog/add-50832-loading-time new file mode 100644 index 00000000000..3b1f7bef150 --- /dev/null +++ b/plugins/woocommerce/changelog/add-50832-loading-time @@ -0,0 +1,4 @@ +Significance: patch +Type: dev + +Track customize_your_store_assembler_hub_editor_loaded event to measure CYS loading time From d6f1cce4244abd6cf8a0e2a77bf4cfdf065d2321 Mon Sep 17 00:00:00 2001 From: Vladimir Reznichenko Date: Wed, 18 Sep 2024 11:10:11 +0200 Subject: [PATCH 03/12] [dev] Monorepo: update git post-checkout hook (feedback) (#51471) --- .husky/post-checkout | 31 +++++++++++++------------------ .husky/post-merge | 2 ++ 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/.husky/post-checkout b/.husky/post-checkout index cddb5753bc3..1485ab1707b 100755 --- a/.husky/post-checkout +++ b/.husky/post-checkout @@ -1,35 +1,30 @@ #!/usr/bin/env bash . "$(dirname "$0")/_/husky.sh" -# '1' is branch +# The hook documentation: https://git-scm.com/docs/githooks.html#_post_checkout CHECKOUT_TYPE=$3 -redColoured='\033[0;31m' +HEAD_NEW=$2 +HEAD_PREVIOUS=$1 + whiteColoured='\033[0m' +orangeColoured='\033[1;33m' +# '1' is a branch checkout if [ "$CHECKOUT_TYPE" = '1' ]; then - canUpdateDependencies='no' - # Prompt about pnpm versions mismatch when switching between branches. - currentPnpmVersion=$( ( command -v pnpm > /dev/null && pnpm -v ) || echo 'n/a' ) + currentPnpmVersion=$( ( command -v pnpm > /dev/null && pnpm -v 2>/dev/null ) || echo 'n/a' ) targetPnpmVersion=$( grep packageManager package.json | sed -nr 's/.+packageManager.+pnpm@([[:digit:].]+).+/\1/p' ) if [ "$currentPnpmVersion" != "$targetPnpmVersion" ]; then - printf "${redColoured}pnpm versions mismatch: in use '$currentPnpmVersion', needed '$targetPnpmVersion'. Here some hints how to solve this:\n" - printf "${redColoured}* actualize environment: 'nvm use && pnpm -v' (the most common case)\n" - printf "${redColoured}* install: 'npm install -g pnpm@$targetPnpmVersion'\n" - else - canUpdateDependencies='yes' + printf "${orangeColoured}pnpm versions mismatch: in use '$currentPnpmVersion', needed '$targetPnpmVersion'. If you are working on something in this branch, here are some hints on how to solve this:\n" + printf "${orangeColoured}* actualize environment: 'nvm use && pnpm -v' (the most common case)\n" + printf "${orangeColoured}* install: 'npm install -g pnpm@$targetPnpmVersion'\n" fi # Auto-refresh dependencies when switching between branches. - changedManifests=$( ( git diff --name-only HEAD ORIG_HEAD | grep -E '(package.json|pnpm-lock.yaml|pnpm-workspace.yaml|composer.json|composer.lock)$' ) || echo '' ) + changedManifests=$( ( git diff --name-only $HEAD_NEW $HEAD_PREVIOUS | grep -E '(package.json|pnpm-lock.yaml|pnpm-workspace.yaml|composer.json|composer.lock)$' ) || echo '' ) if [ -n "$changedManifests" ]; then - printf "${whiteColoured}It was a change in the following file(s) - refreshing dependencies:\n" + printf "${whiteColoured}The following file(s) in the new branch differs from the original one, dependencies might need to be refreshed:\n" printf "${whiteColoured} %s\n" $changedManifests - - if [ "$canUpdateDependencies" = 'yes' ]; then - pnpm install --frozen-lockfile - else - printf "${redColoured}Skipping dependencies refresh. Please actualize pnpm version and execute 'pnpm install --frozen-lockfile' manually.\n" - fi + printf "${orangeColoured}If you are working on something in this branch, ensure to refresh dependencies with 'pnpm install --frozen-lockfile'\n" fi fi diff --git a/.husky/post-merge b/.husky/post-merge index 7ff64bebced..bc022e8fede 100755 --- a/.husky/post-merge +++ b/.husky/post-merge @@ -1,6 +1,8 @@ #!/usr/bin/env bash . "$(dirname "$0")/_/husky.sh" +# The hook documentation: https://git-scm.com/docs/githooks.html#_post_merge + changedManifests=$( ( git diff --name-only HEAD ORIG_HEAD | grep -E '(package.json|pnpm-lock.yaml|pnpm-workspace.yaml|composer.json|composer.lock)$' ) || echo '' ) if [ -n "$changedManifests" ]; then printf "It was a change in the following file(s) - refreshing dependencies:\n" From 6a83e8d301d51d7ea114b146695df45d8f1a2a1f Mon Sep 17 00:00:00 2001 From: Ilyas Foo Date: Wed, 18 Sep 2024 18:06:38 +0800 Subject: [PATCH 04/12] Fix deprecation notice in coming soon and store-only mode (#51474) * Wrap parse_str under a check * Changelog --- .../changelog/fix-51472-fix-deprecation-notice | 4 ++++ plugins/woocommerce/src/Admin/WCAdminHelper.php | 12 +++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) create mode 100644 plugins/woocommerce/changelog/fix-51472-fix-deprecation-notice diff --git a/plugins/woocommerce/changelog/fix-51472-fix-deprecation-notice b/plugins/woocommerce/changelog/fix-51472-fix-deprecation-notice new file mode 100644 index 00000000000..79ce818f52b --- /dev/null +++ b/plugins/woocommerce/changelog/fix-51472-fix-deprecation-notice @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Wrap parse_str under a check to resolve deprecation notice diff --git a/plugins/woocommerce/src/Admin/WCAdminHelper.php b/plugins/woocommerce/src/Admin/WCAdminHelper.php index 9e234762eb8..768102e35fb 100644 --- a/plugins/woocommerce/src/Admin/WCAdminHelper.php +++ b/plugins/woocommerce/src/Admin/WCAdminHelper.php @@ -154,11 +154,13 @@ class WCAdminHelper { 'post_type' => 'product', ); - parse_str( wp_parse_url( $url, PHP_URL_QUERY ), $url_params ); - - foreach ( $params as $key => $param ) { - if ( isset( $url_params[ $key ] ) && $url_params[ $key ] === $param ) { - return true; + $query_string = wp_parse_url( $url, PHP_URL_QUERY ); + if ( $query_string ) { + parse_str( $query_string, $url_params ); + foreach ( $params as $key => $param ) { + if ( isset( $url_params[ $key ] ) && $url_params[ $key ] === $param ) { + return true; + } } } From 3df48f2bd75550d1252cc3ba0610e6dac3183b24 Mon Sep 17 00:00:00 2001 From: Naman Malhotra Date: Wed, 18 Sep 2024 14:12:23 +0300 Subject: [PATCH 05/12] Revert low stock notification changes (#51441) * Revert - Change when stock notif emails are triggered * added changelog * restored comment * restored more comment * lint fixes * Add docblock. --------- Co-authored-by: Vedanshu Jain --- .../changelog/revert-low-stock-notification | 4 + .../class-wc-product-data-store-cpt.php | 24 ++--- .../includes/wc-stock-functions.php | 34 ++++-- .../php/includes/wc-stock-functions-tests.php | 100 ------------------ 4 files changed, 41 insertions(+), 121 deletions(-) create mode 100644 plugins/woocommerce/changelog/revert-low-stock-notification diff --git a/plugins/woocommerce/changelog/revert-low-stock-notification b/plugins/woocommerce/changelog/revert-low-stock-notification new file mode 100644 index 00000000000..3ec370d906f --- /dev/null +++ b/plugins/woocommerce/changelog/revert-low-stock-notification @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Revert - changes related to low stock product notification diff --git a/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php index b5a611d6f8a..b6da3a8e879 100644 --- a/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php +++ b/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php @@ -656,21 +656,21 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da // Fire actions to let 3rd parties know the stock is about to be changed. if ( $product->is_type( 'variation' ) ) { /** - * Action to signal that the value of 'stock_quantity' for a variation is about to change. - * - * @param WC_Product $product The variation whose stock is about to change. - * - * @since 4.9 - */ + * Action to signal that the value of 'stock_quantity' for a variation is about to change. + * + * @since 4.9 + * + * @param int $product The variation whose stock is about to change. + */ do_action( 'woocommerce_variation_before_set_stock', $product ); } else { /** - * Action to signal that the value of 'stock_quantity' for a product is about to change. - * - * @param WC_Product $product The product whose stock is about to change. - * - * @since 4.9 - */ + * Action to signal that the value of 'stock_quantity' for a product is about to change. + * + * @since 4.9 + * + * @param int $product The product whose stock is about to change. + */ do_action( 'woocommerce_product_before_set_stock', $product ); } break; diff --git a/plugins/woocommerce/includes/wc-stock-functions.php b/plugins/woocommerce/includes/wc-stock-functions.php index 1489a0e6630..e81b31e8c50 100644 --- a/plugins/woocommerce/includes/wc-stock-functions.php +++ b/plugins/woocommerce/includes/wc-stock-functions.php @@ -242,10 +242,31 @@ function wc_trigger_stock_change_notifications( $order, $changes ) { return; } - $order_notes = array(); + $order_notes = array(); + $no_stock_amount = absint( get_option( 'woocommerce_notify_no_stock_amount', 0 ) ); foreach ( $changes as $change ) { - $order_notes[] = $change['product']->get_formatted_name() . ' ' . $change['from'] . '→' . $change['to']; + $order_notes[] = $change['product']->get_formatted_name() . ' ' . $change['from'] . '→' . $change['to']; + $low_stock_amount = absint( wc_get_low_stock_amount( wc_get_product( $change['product']->get_id() ) ) ); + if ( $change['to'] <= $no_stock_amount ) { + /** + * Action to signal that the value of 'stock_quantity' for a variation is about to change. + * + * @since 4.9 + * + * @param int $product The variation whose stock is about to change. + */ + do_action( 'woocommerce_no_stock', wc_get_product( $change['product']->get_id() ) ); + } elseif ( $change['to'] <= $low_stock_amount ) { + /** + * Action to signal that the value of 'stock_quantity' for a product is about to change. + * + * @since 4.9 + * + * @param int $product The product whose stock is about to change. + */ + do_action( 'woocommerce_low_stock', wc_get_product( $change['product']->get_id() ) ); + } if ( $change['to'] < 0 ) { /** @@ -312,8 +333,6 @@ function wc_trigger_stock_change_actions( $product ) { do_action( 'woocommerce_low_stock', $product ); } } -add_action( 'woocommerce_variation_set_stock', 'wc_trigger_stock_change_actions' ); -add_action( 'woocommerce_product_set_stock', 'wc_trigger_stock_change_actions' ); /** * Increase stock levels for items within an order. @@ -485,11 +504,8 @@ function wc_get_low_stock_amount( WC_Product $product ) { $low_stock_amount = $product->get_low_stock_amount(); if ( '' === $low_stock_amount && $product->is_type( 'variation' ) ) { - $parent_product = wc_get_product( $product->get_parent_id() ); - - if ( $parent_product instanceof WC_Product ) { - $low_stock_amount = $parent_product->get_low_stock_amount(); - } + $product = wc_get_product( $product->get_parent_id() ); + $low_stock_amount = $product->get_low_stock_amount(); } if ( '' === $low_stock_amount ) { diff --git a/plugins/woocommerce/tests/php/includes/wc-stock-functions-tests.php b/plugins/woocommerce/tests/php/includes/wc-stock-functions-tests.php index 90f312be98b..3e8bbb7bed8 100644 --- a/plugins/woocommerce/tests/php/includes/wc-stock-functions-tests.php +++ b/plugins/woocommerce/tests/php/includes/wc-stock-functions-tests.php @@ -356,104 +356,4 @@ class WC_Stock_Functions_Tests extends \WC_Unit_Test_Case { $this->assertIsIntAndEquals( $site_wide_low_stock_amount, wc_get_low_stock_amount( $var1 ) ); } - - /** - * @testdox Test that the `woocommerce_low_stock` action fires when a product stock hits the low stock threshold. - */ - public function test_wc_update_product_stock_low_stock_action() { - $product = WC_Helper_Product::create_simple_product(); - $product->set_manage_stock( true ); - $product->save(); - - $low_stock_amount = wc_get_low_stock_amount( $product ); - $initial_stock = $low_stock_amount + 2; - - wc_update_product_stock( $product->get_id(), $initial_stock ); - - $action_fired = false; - $callback = function () use ( &$action_fired ) { - $action_fired = true; - }; - add_action( 'woocommerce_low_stock', $callback ); - - // Test with `wc_update_product_stock`. - wc_update_product_stock( $product->get_id(), 1, 'decrease' ); - $this->assertFalse( $action_fired ); - wc_update_product_stock( $product->get_id(), 1, 'decrease' ); - $this->assertTrue( $action_fired ); - - $action_fired = false; - - // Test with the data store. - $product->set_stock_quantity( $initial_stock ); - $product->save(); - $this->assertFalse( $action_fired ); - $product->set_stock_quantity( $low_stock_amount ); - $product->save(); - $this->assertTrue( $action_fired ); - - remove_action( 'woocommerce_low_stock', $callback ); - } - - /** - * @testdox Test that the `woocommerce_no_stock` action fires when a product stock hits the no stock threshold. - */ - public function test_wc_update_product_stock_no_stock_action() { - $product = WC_Helper_Product::create_simple_product(); - $product->set_manage_stock( true ); - $product->save(); - - $no_stock_amount = get_option( 'woocommerce_notify_no_stock_amount', 0 ); - $initial_stock = $no_stock_amount + 2; - - wc_update_product_stock( $product->get_id(), $initial_stock ); - - $action_fired = false; - $callback = function () use ( &$action_fired ) { - $action_fired = true; - }; - add_action( 'woocommerce_no_stock', $callback ); - - // Test with `wc_update_product_stock`. - wc_update_product_stock( $product->get_id(), 1, 'decrease' ); - $this->assertFalse( $action_fired ); - wc_update_product_stock( $product->get_id(), 1, 'decrease' ); - $this->assertTrue( $action_fired ); - - $action_fired = false; - - // Test with the data store. - $product->set_stock_quantity( $initial_stock ); - $product->save(); - $this->assertFalse( $action_fired ); - $product->set_stock_quantity( $no_stock_amount ); - $product->save(); - $this->assertTrue( $action_fired ); - - remove_action( 'woocommerce_no_stock', $callback ); - } - - /** - * @testdox The wc_trigger_stock_change_actions function should only trigger actions if the product is set - * to manage stock. - */ - public function test_wc_trigger_stock_change_actions_bails_early_for_unmanaged_stock() { - $action_fired = false; - $callback = function () use ( &$action_fired ) { - $action_fired = true; - }; - add_action( 'woocommerce_no_stock', $callback ); - - $product = WC_Helper_Product::create_simple_product(); - - $this->assertFalse( $action_fired ); - - $product->set_manage_stock( true ); - $product->set_stock_quantity( 0 ); - $product->save(); - - $this->assertTrue( $action_fired ); - - remove_action( 'woocommerce_no_stock', $callback ); - } } From 16e072879bb8fcd062c53c47cdef8dd7b2d8a8cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=A9stor=20Soriano?= Date: Wed, 18 Sep 2024 13:15:15 +0200 Subject: [PATCH 06/12] Fix: "Import file path is invalid" in Windows (#51456) * Fix WC_Product_CSV_Importer_Controller::check_file_path failing on Windows --- plugins/woocommerce/changelog/pr-51456 | 4 ++++ .../importers/class-wc-product-csv-importer-controller.php | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 plugins/woocommerce/changelog/pr-51456 diff --git a/plugins/woocommerce/changelog/pr-51456 b/plugins/woocommerce/changelog/pr-51456 new file mode 100644 index 00000000000..9d71edd5fbf --- /dev/null +++ b/plugins/woocommerce/changelog/pr-51456 @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Fix invalid path error in product importer in Windows diff --git a/plugins/woocommerce/includes/admin/importers/class-wc-product-csv-importer-controller.php b/plugins/woocommerce/includes/admin/importers/class-wc-product-csv-importer-controller.php index cadf53a7bc5..0ce9d749a2b 100644 --- a/plugins/woocommerce/includes/admin/importers/class-wc-product-csv-importer-controller.php +++ b/plugins/woocommerce/includes/admin/importers/class-wc-product-csv-importer-controller.php @@ -125,6 +125,7 @@ class WC_Product_CSV_Importer_Controller { // Check that file is within an allowed location. if ( $is_valid_file ) { + $normalized_path = wp_normalize_path( $path ); $in_valid_location = false; $valid_locations = array(); $valid_locations[] = ABSPATH; @@ -135,7 +136,8 @@ class WC_Product_CSV_Importer_Controller { } foreach ( $valid_locations as $valid_location ) { - if ( 0 === stripos( $path, trailingslashit( realpath( $valid_location ) ) ) ) { + $normalized_location = wp_normalize_path( realpath( $valid_location ) ); + if ( 0 === stripos( $normalized_path, trailingslashit( $normalized_location ) ) ) { $in_valid_location = true; break; } From 4bc4649008d0ea0dc2c5409304a88b2b84f5308d Mon Sep 17 00:00:00 2001 From: Ivan Stojadinov Date: Wed, 18 Sep 2024 13:24:38 +0200 Subject: [PATCH 07/12] [e2e] External - Expand WPCOM suite, part 3 (#51422) * Skip "Restricted coupon management" - error 500 * Make `wooPatterns` element more specific * Skip "Merchant can view a list of all customers" - more customers due to using existing website * No LYS on WPCOM * Dismiss notice if shown * Expand WPCOM suite * Click on `Canada` * Click on `Canada` * Be more specific about `Delete` button * Add changefile(s) from automation for the following project(s): woocommerce * Skip two more tests - WC patterns and block * Remove usage of `networkidle` * Handle notice if present with `addLocatorHandler` * Fix ESLint issue --------- Co-authored-by: github-actions --- ...1422-e2e-external-expand-wpcom-suite-part3 | 4 + .../envs/default-wpcom/playwright.config.js | 10 + .../create-restricted-coupons.spec.js | 787 +++++++++--------- .../merchant/create-shipping-zones.spec.js | 37 +- .../create-woocommerce-blocks.spec.js | 9 +- .../create-woocommerce-patterns.spec.js | 13 +- .../tests/merchant/customer-list.spec.js | 2 +- .../merchant/customer-payment-page.spec.js | 11 + .../tests/merchant/launch-your-store.spec.js | 2 +- 9 files changed, 461 insertions(+), 414 deletions(-) create mode 100644 plugins/woocommerce/changelog/51422-e2e-external-expand-wpcom-suite-part3 diff --git a/plugins/woocommerce/changelog/51422-e2e-external-expand-wpcom-suite-part3 b/plugins/woocommerce/changelog/51422-e2e-external-expand-wpcom-suite-part3 new file mode 100644 index 00000000000..4eeb743cc9d --- /dev/null +++ b/plugins/woocommerce/changelog/51422-e2e-external-expand-wpcom-suite-part3 @@ -0,0 +1,4 @@ +Significance: patch +Type: update + +Expand the e2e suite we're running on WPCOM part #3. \ No newline at end of file diff --git a/plugins/woocommerce/tests/e2e-pw/envs/default-wpcom/playwright.config.js b/plugins/woocommerce/tests/e2e-pw/envs/default-wpcom/playwright.config.js index 27ec548c34a..a3e2135c0ad 100644 --- a/plugins/woocommerce/tests/e2e-pw/envs/default-wpcom/playwright.config.js +++ b/plugins/woocommerce/tests/e2e-pw/envs/default-wpcom/playwright.config.js @@ -23,6 +23,16 @@ config = { '**/merchant/create-order.spec.js', '**/merchant/create-page.spec.js', '**/merchant/create-post.spec.js', + '**/merchant/create-restricted-coupons.spec.js', + '**/merchant/create-shipping-classes.spec.js', + '**/merchant/create-shipping-zones.spec.js', + '**/merchant/create-woocommerce-blocks.spec.js', + '**/merchant/create-woocommerce-patterns.spec.js', + '**/merchant/customer-list.spec.js', + '**/merchant/customer-payment-page.spec.js', + '**/merchant/launch-your-store.spec.js', + '**/merchant/lost-password.spec.js', + '**/merchant/order-bulk-edit.spec.js', ], grepInvert: /@skip-on-default-wpcom/, }, diff --git a/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-restricted-coupons.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-restricted-coupons.spec.js index 3fd0b4c1717..2d6f69e7eb0 100644 --- a/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-restricted-coupons.spec.js +++ b/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-restricted-coupons.spec.js @@ -103,397 +103,414 @@ const test = baseTest.extend( { }, } ); -test.describe( 'Restricted coupon management', { tag: [ '@services' ] }, () => { - for ( const couponType of Object.keys( couponData ) ) { - test( `can create new ${ couponType } coupon`, async ( { - page, - coupon, - product, - } ) => { - // create basics for the coupon - await test.step( 'add new coupon', async () => { - await page.goto( - 'wp-admin/post-new.php?post_type=shop_coupon' - ); - await page - .getByLabel( 'Coupon code' ) - .fill( couponData[ couponType ].code ); - await page - .getByPlaceholder( 'Description (optional)' ) - .fill( couponData[ couponType ].description ); - await page - .getByPlaceholder( '0' ) - .fill( couponData[ couponType ].amount ); - await expect( page.getByText( 'Move to Trash' ) ).toBeVisible(); - } ); - - // set up the restrictions for each coupon type - // set minimum spend - if ( couponType === 'minimumSpend' ) { - await test.step( 'set minimum spend coupon', async () => { - await page - .getByRole( 'link', { - name: 'Usage restriction', - } ) - .click(); - await page - .getByPlaceholder( 'No minimum' ) - .fill( couponData[ couponType ].minSpend ); - } ); - } - // set maximum spend - if ( couponType === 'maximumSpend' ) { - await test.step( 'set maximum spend coupon', async () => { - await page - .getByRole( 'link', { - name: 'Usage restriction', - } ) - .click(); - await page - .getByPlaceholder( 'No maximum' ) - .fill( couponData[ couponType ].maxSpend ); - } ); - } - // set individual use - if ( couponType === 'individualUse' ) { - await test.step( 'set individual use coupon', async () => { - await page - .getByRole( 'link', { - name: 'Usage restriction', - } ) - .click(); - await page.getByLabel( 'Individual use only' ).check(); - } ); - } - // set exclude sale items - if ( couponType === 'excludeSaleItems' ) { - await test.step( 'set exclude sale items coupon', async () => { - await page - .getByRole( 'link', { - name: 'Usage restriction', - } ) - .click(); - await page.getByLabel( 'Exclude sale items' ).check(); - } ); - } - // set product categories - if ( couponType === 'productCategories' ) { - await test.step( 'set product categories coupon', async () => { - await page - .getByRole( 'link', { - name: 'Usage restriction', - } ) - .click(); - await page - .getByPlaceholder( 'Any category' ) - .pressSequentially( 'Uncategorized' ); - await page - .getByRole( 'option', { name: 'Uncategorized' } ) - .click(); - } ); - } - // set exclude product categories - if ( couponType === 'excludeProductCategories' ) { - await test.step( 'set exclude product categories coupon', async () => { - await page - .getByRole( 'link', { - name: 'Usage restriction', - } ) - .click(); - await page - .getByPlaceholder( 'No categories' ) - .pressSequentially( 'Uncategorized' ); - await page - .getByRole( 'option', { name: 'Uncategorized' } ) - .click(); - } ); - } - - // Skip Brands tests while behind a feature flag. - const skipBrandsTests = true; - - // set exclude product brands - if ( couponType === 'excludeProductBrands' && ! skipBrandsTests ) { - await test.step( 'set exclude product brands coupon', async () => { - await page - .getByRole( 'link', { - name: 'Usage restriction', - } ) - .click(); - await page - .getByPlaceholder( 'No brands' ) - .pressSequentially( 'WooCommerce Apparels' ); - await page - .getByRole( 'option', { name: 'WooCommerce Apparels' } ) - .click(); - } ); - } - // set products - if ( couponType === 'products' ) { - await test.step( 'set products coupon', async () => { - await page - .getByRole( 'link', { - name: 'Usage restriction', - } ) - .click(); - await page - .getByPlaceholder( 'Search for a product…' ) - .first() - .pressSequentially( product.name ); - await page - .getByRole( 'option', { name: product.name } ) - .click(); - } ); - } - // set exclude products - if ( couponType === 'excludeProducts' ) { - await test.step( 'set exclude products coupon', async () => { - await page - .getByRole( 'link', { - name: 'Usage restriction', - } ) - .click(); - await page - .getByPlaceholder( 'Search for a product…' ) - .last() - .pressSequentially( product.name ); - await page - .getByRole( 'option', { name: product.name } ) - .click(); - } ); - } - // set allowed emails - if ( couponType === 'allowedEmails' ) { - await test.step( 'set allowed emails coupon', async () => { - await page - .getByRole( 'link', { - name: 'Usage restriction', - } ) - .click(); - await page - .getByPlaceholder( 'No restrictions' ) - .fill( couponData[ couponType ].allowedEmails[ 0 ] ); - } ); - } - // set usage limit - if ( couponType === 'usageLimitPerCoupon' ) { - await test.step( 'set usage limit coupon', async () => { - await page - .getByRole( 'link', { name: 'Usage limits' } ) - .click(); - await page - .getByLabel( 'Usage limit per coupon' ) - .fill( couponData[ couponType ].usageLimit ); - } ); - } - // set usage limit per user - if ( couponType === 'usageLimitPerUser' ) { - await test.step( 'set usage limit per user coupon', async () => { - await page - .getByRole( 'link', { name: 'Usage limits' } ) - .click(); - await page - .getByLabel( 'Usage limit per user' ) - .fill( couponData[ couponType ].usageLimitPerUser ); - } ); - } - - // publish the coupon and retrieve the id - await test.step( 'publish the coupon', async () => { - await page - .getByRole( 'button', { name: 'Publish', exact: true } ) - .click(); - await expect( - page.getByText( 'Coupon updated.' ) - ).toBeVisible(); - coupon.id = page.url().match( /(?<=post=)\d+/ )[ 0 ]; - expect( coupon.id ).toBeDefined(); - } ); - - // verify the creation of the coupon and basic details - await test.step( 'verify coupon creation', async () => { - await page.goto( 'wp-admin/edit.php?post_type=shop_coupon' ); - await expect( - page.getByRole( 'cell', { - name: couponData[ couponType ].code, - } ) - ).toBeVisible(); - await expect( - page.getByRole( 'cell', { - name: couponData[ couponType ].description, - } ) - ).toBeVisible(); - await expect( - page.getByRole( 'cell', { - name: couponData[ couponType ].amount, - exact: true, - } ) - ).toBeVisible(); - - await page - .getByRole( 'link', { - name: couponData[ couponType ].code, - } ) - .first() - .click(); - } ); - - // verify the restrictions for each coupon type - // verify minimum spend - if ( couponType === 'minimumSpend' ) { - await test.step( 'verify minimum spend coupon', async () => { - await page - .getByRole( 'link', { - name: 'Usage restriction', - } ) - .click(); - await expect( - page.getByPlaceholder( 'No minimum' ) - ).toHaveValue( couponData[ couponType ].minSpend ); - } ); - } - - // verify maximum spend - if ( couponType === 'maximumSpend' ) { - await test.step( 'verify maximum spend coupon', async () => { - await page - .getByRole( 'link', { - name: 'Usage restriction', - } ) - .click(); - await expect( - page.getByPlaceholder( 'No maximum' ) - ).toHaveValue( couponData[ couponType ].maxSpend ); - } ); - } - - // verify individual use - if ( couponType === 'individualUse' ) { - await test.step( 'verify individual use coupon', async () => { - await page - .getByRole( 'link', { - name: 'Usage restriction', - } ) - .click(); - await expect( - page.getByLabel( 'Individual use only' ) - ).toBeChecked(); - } ); - } - - // verify exclude sale items - if ( couponType === 'excludeSaleItems' ) { - await test.step( 'verify exclude sale items coupon', async () => { - await page - .getByRole( 'link', { - name: 'Usage restriction', - } ) - .click(); - await expect( - page.getByLabel( 'Exclude sale items' ) - ).toBeChecked(); - } ); - } - - // verify product categories - if ( couponType === 'productCategories' ) { - await test.step( 'verify product categories coupon', async () => { - await page - .getByRole( 'link', { - name: 'Usage restriction', - } ) - .click(); - await expect( - page.getByRole( 'listitem', { - name: 'Uncategorized', - } ) - ).toBeVisible(); - } ); - } - - // verify exclude product categories - if ( couponType === 'excludeProductCategories' ) { - await test.step( 'verify exclude product categories coupon', async () => { - await page - .getByRole( 'link', { - name: 'Usage restriction', - } ) - .click(); - await expect( - page.getByRole( 'listitem', { - name: 'Uncategorized', - } ) - ).toBeVisible(); - } ); - } - - // verify products - if ( couponType === 'products' ) { - await test.step( 'verify products coupon', async () => { - await page - .getByRole( 'link', { - name: 'Usage restriction', - } ) - .click(); - await expect( - page.getByRole( 'listitem', { name: product.name } ) - ).toBeVisible(); - } ); - } - - // verify exclude products - if ( couponType === 'excludeProducts' ) { - await test.step( 'verify exclude products coupon', async () => { - await page - .getByRole( 'link', { - name: 'Usage restriction', - } ) - .click(); - await expect( - page.getByRole( 'listitem', { name: product.name } ) - ).toBeVisible(); - } ); - } - - // verify allowed emails - if ( couponType === 'allowedEmails' ) { - await test.step( 'verify allowed emails coupon', async () => { - await page - .getByRole( 'link', { - name: 'Usage restriction', - } ) - .click(); - await expect( - page.getByPlaceholder( 'No restrictions' ) - ).toHaveValue( - couponData[ couponType ].allowedEmails[ 0 ] +test.describe( + 'Restricted coupon management', + { tag: [ '@services', '@skip-on-default-wpcom' ] }, + () => { + for ( const couponType of Object.keys( couponData ) ) { + test( `can create new ${ couponType } coupon`, async ( { + page, + coupon, + product, + } ) => { + // create basics for the coupon + await test.step( 'add new coupon', async () => { + await page.goto( + 'wp-admin/post-new.php?post_type=shop_coupon' ); - } ); - } - - // verify usage limit - if ( couponType === 'usageLimitPerCoupon' ) { - await test.step( 'verify usage limit coupon', async () => { await page - .getByRole( 'link', { name: 'Usage limits' } ) + .getByLabel( 'Coupon code' ) + .fill( couponData[ couponType ].code ); + await page + .getByPlaceholder( 'Description (optional)' ) + .fill( couponData[ couponType ].description ); + await page + .getByPlaceholder( '0' ) + .fill( couponData[ couponType ].amount ); + await expect( + page.getByText( 'Move to Trash' ) + ).toBeVisible(); + } ); + + // set up the restrictions for each coupon type + // set minimum spend + if ( couponType === 'minimumSpend' ) { + await test.step( 'set minimum spend coupon', async () => { + await page + .getByRole( 'link', { + name: 'Usage restriction', + } ) + .click(); + await page + .getByPlaceholder( 'No minimum' ) + .fill( couponData[ couponType ].minSpend ); + } ); + } + // set maximum spend + if ( couponType === 'maximumSpend' ) { + await test.step( 'set maximum spend coupon', async () => { + await page + .getByRole( 'link', { + name: 'Usage restriction', + } ) + .click(); + await page + .getByPlaceholder( 'No maximum' ) + .fill( couponData[ couponType ].maxSpend ); + } ); + } + // set individual use + if ( couponType === 'individualUse' ) { + await test.step( 'set individual use coupon', async () => { + await page + .getByRole( 'link', { + name: 'Usage restriction', + } ) + .click(); + await page.getByLabel( 'Individual use only' ).check(); + } ); + } + // set exclude sale items + if ( couponType === 'excludeSaleItems' ) { + await test.step( 'set exclude sale items coupon', async () => { + await page + .getByRole( 'link', { + name: 'Usage restriction', + } ) + .click(); + await page.getByLabel( 'Exclude sale items' ).check(); + } ); + } + // set product categories + if ( couponType === 'productCategories' ) { + await test.step( 'set product categories coupon', async () => { + await page + .getByRole( 'link', { + name: 'Usage restriction', + } ) + .click(); + await page + .getByPlaceholder( 'Any category' ) + .pressSequentially( 'Uncategorized' ); + await page + .getByRole( 'option', { name: 'Uncategorized' } ) + .click(); + } ); + } + // set exclude product categories + if ( couponType === 'excludeProductCategories' ) { + await test.step( 'set exclude product categories coupon', async () => { + await page + .getByRole( 'link', { + name: 'Usage restriction', + } ) + .click(); + await page + .getByPlaceholder( 'No categories' ) + .pressSequentially( 'Uncategorized' ); + await page + .getByRole( 'option', { name: 'Uncategorized' } ) + .click(); + } ); + } + + // Skip Brands tests while behind a feature flag. + const skipBrandsTests = true; + + // set exclude product brands + if ( + couponType === 'excludeProductBrands' && + ! skipBrandsTests + ) { + await test.step( 'set exclude product brands coupon', async () => { + await page + .getByRole( 'link', { + name: 'Usage restriction', + } ) + .click(); + await page + .getByPlaceholder( 'No brands' ) + .pressSequentially( 'WooCommerce Apparels' ); + await page + .getByRole( 'option', { + name: 'WooCommerce Apparels', + } ) + .click(); + } ); + } + // set products + if ( couponType === 'products' ) { + await test.step( 'set products coupon', async () => { + await page + .getByRole( 'link', { + name: 'Usage restriction', + } ) + .click(); + await page + .getByPlaceholder( 'Search for a product…' ) + .first() + .pressSequentially( product.name ); + await page + .getByRole( 'option', { name: product.name } ) + .click(); + } ); + } + // set exclude products + if ( couponType === 'excludeProducts' ) { + await test.step( 'set exclude products coupon', async () => { + await page + .getByRole( 'link', { + name: 'Usage restriction', + } ) + .click(); + await page + .getByPlaceholder( 'Search for a product…' ) + .last() + .pressSequentially( product.name ); + await page + .getByRole( 'option', { name: product.name } ) + .click(); + } ); + } + // set allowed emails + if ( couponType === 'allowedEmails' ) { + await test.step( 'set allowed emails coupon', async () => { + await page + .getByRole( 'link', { + name: 'Usage restriction', + } ) + .click(); + await page + .getByPlaceholder( 'No restrictions' ) + .fill( + couponData[ couponType ].allowedEmails[ 0 ] + ); + } ); + } + // set usage limit + if ( couponType === 'usageLimitPerCoupon' ) { + await test.step( 'set usage limit coupon', async () => { + await page + .getByRole( 'link', { name: 'Usage limits' } ) + .click(); + await page + .getByLabel( 'Usage limit per coupon' ) + .fill( couponData[ couponType ].usageLimit ); + } ); + } + // set usage limit per user + if ( couponType === 'usageLimitPerUser' ) { + await test.step( 'set usage limit per user coupon', async () => { + await page + .getByRole( 'link', { name: 'Usage limits' } ) + .click(); + await page + .getByLabel( 'Usage limit per user' ) + .fill( couponData[ couponType ].usageLimitPerUser ); + } ); + } + + // publish the coupon and retrieve the id + await test.step( 'publish the coupon', async () => { + await page + .getByRole( 'button', { name: 'Publish', exact: true } ) .click(); await expect( - page.getByLabel( 'Usage limit per coupon' ) - ).toHaveValue( couponData[ couponType ].usageLimit ); + page.getByText( 'Coupon updated.' ) + ).toBeVisible(); + coupon.id = page.url().match( /(?<=post=)\d+/ )[ 0 ]; + expect( coupon.id ).toBeDefined(); } ); - } - // verify usage limit per user - if ( couponType === 'usageLimitPerUser' ) { - await test.step( 'verify usage limit per user coupon', async () => { - await page - .getByRole( 'link', { name: 'Usage limits' } ) - .click(); + // verify the creation of the coupon and basic details + await test.step( 'verify coupon creation', async () => { + await page.goto( + 'wp-admin/edit.php?post_type=shop_coupon' + ); await expect( - page.getByLabel( 'Usage limit per user' ) - ).toHaveValue( couponData[ couponType ].usageLimitPerUser ); + page.getByRole( 'cell', { + name: couponData[ couponType ].code, + } ) + ).toBeVisible(); + await expect( + page.getByRole( 'cell', { + name: couponData[ couponType ].description, + } ) + ).toBeVisible(); + await expect( + page.getByRole( 'cell', { + name: couponData[ couponType ].amount, + exact: true, + } ) + ).toBeVisible(); + + await page + .getByRole( 'link', { + name: couponData[ couponType ].code, + } ) + .first() + .click(); } ); - } - } ); + + // verify the restrictions for each coupon type + // verify minimum spend + if ( couponType === 'minimumSpend' ) { + await test.step( 'verify minimum spend coupon', async () => { + await page + .getByRole( 'link', { + name: 'Usage restriction', + } ) + .click(); + await expect( + page.getByPlaceholder( 'No minimum' ) + ).toHaveValue( couponData[ couponType ].minSpend ); + } ); + } + + // verify maximum spend + if ( couponType === 'maximumSpend' ) { + await test.step( 'verify maximum spend coupon', async () => { + await page + .getByRole( 'link', { + name: 'Usage restriction', + } ) + .click(); + await expect( + page.getByPlaceholder( 'No maximum' ) + ).toHaveValue( couponData[ couponType ].maxSpend ); + } ); + } + + // verify individual use + if ( couponType === 'individualUse' ) { + await test.step( 'verify individual use coupon', async () => { + await page + .getByRole( 'link', { + name: 'Usage restriction', + } ) + .click(); + await expect( + page.getByLabel( 'Individual use only' ) + ).toBeChecked(); + } ); + } + + // verify exclude sale items + if ( couponType === 'excludeSaleItems' ) { + await test.step( 'verify exclude sale items coupon', async () => { + await page + .getByRole( 'link', { + name: 'Usage restriction', + } ) + .click(); + await expect( + page.getByLabel( 'Exclude sale items' ) + ).toBeChecked(); + } ); + } + + // verify product categories + if ( couponType === 'productCategories' ) { + await test.step( 'verify product categories coupon', async () => { + await page + .getByRole( 'link', { + name: 'Usage restriction', + } ) + .click(); + await expect( + page.getByRole( 'listitem', { + name: 'Uncategorized', + } ) + ).toBeVisible(); + } ); + } + + // verify exclude product categories + if ( couponType === 'excludeProductCategories' ) { + await test.step( 'verify exclude product categories coupon', async () => { + await page + .getByRole( 'link', { + name: 'Usage restriction', + } ) + .click(); + await expect( + page.getByRole( 'listitem', { + name: 'Uncategorized', + } ) + ).toBeVisible(); + } ); + } + + // verify products + if ( couponType === 'products' ) { + await test.step( 'verify products coupon', async () => { + await page + .getByRole( 'link', { + name: 'Usage restriction', + } ) + .click(); + await expect( + page.getByRole( 'listitem', { name: product.name } ) + ).toBeVisible(); + } ); + } + + // verify exclude products + if ( couponType === 'excludeProducts' ) { + await test.step( 'verify exclude products coupon', async () => { + await page + .getByRole( 'link', { + name: 'Usage restriction', + } ) + .click(); + await expect( + page.getByRole( 'listitem', { name: product.name } ) + ).toBeVisible(); + } ); + } + + // verify allowed emails + if ( couponType === 'allowedEmails' ) { + await test.step( 'verify allowed emails coupon', async () => { + await page + .getByRole( 'link', { + name: 'Usage restriction', + } ) + .click(); + await expect( + page.getByPlaceholder( 'No restrictions' ) + ).toHaveValue( + couponData[ couponType ].allowedEmails[ 0 ] + ); + } ); + } + + // verify usage limit + if ( couponType === 'usageLimitPerCoupon' ) { + await test.step( 'verify usage limit coupon', async () => { + await page + .getByRole( 'link', { name: 'Usage limits' } ) + .click(); + await expect( + page.getByLabel( 'Usage limit per coupon' ) + ).toHaveValue( couponData[ couponType ].usageLimit ); + } ); + } + + // verify usage limit per user + if ( couponType === 'usageLimitPerUser' ) { + await test.step( 'verify usage limit per user coupon', async () => { + await page + .getByRole( 'link', { name: 'Usage limits' } ) + .click(); + await expect( + page.getByLabel( 'Usage limit per user' ) + ).toHaveValue( + couponData[ couponType ].usageLimitPerUser + ); + } ); + } + } ); + } } -} ); +); diff --git a/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-shipping-zones.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-shipping-zones.spec.js index 93bc2a1d605..37dbf85ec6d 100644 --- a/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-shipping-zones.spec.js +++ b/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-shipping-zones.spec.js @@ -60,8 +60,7 @@ test.describe( 'WooCommerce Shipping Settings - Add new shipping zone', () => { // this shipping zone already exists, don't create it } else { await page.goto( - 'wp-admin/admin.php?page=wc-settings&tab=shipping&zone_id=new', - { waitUntil: 'networkidle' } + 'wp-admin/admin.php?page=wc-settings&tab=shipping&zone_id=new' ); await page .getByPlaceholder( 'Zone name' ) @@ -92,10 +91,8 @@ test.describe( 'WooCommerce Shipping Settings - Add new shipping zone', () => { .getByRole( 'button', { name: 'Continue' } ) .last() .click(); - await page.waitForLoadState( 'networkidle' ); await page.locator( '#btn-ok' ).click(); - await page.waitForLoadState( 'networkidle' ); await expect( page @@ -132,8 +129,7 @@ test.describe( 'WooCommerce Shipping Settings - Add new shipping zone', () => { // this shipping zone already exists, don't create it } else { await page.goto( - 'wp-admin/admin.php?page=wc-settings&tab=shipping&zone_id=new', - { waitUntil: 'networkidle' } + 'wp-admin/admin.php?page=wc-settings&tab=shipping&zone_id=new' ); await page .getByPlaceholder( 'Zone name' ) @@ -159,10 +155,8 @@ test.describe( 'WooCommerce Shipping Settings - Add new shipping zone', () => { .getByRole( 'button', { name: 'Continue' } ) .last() .click(); - await page.waitForLoadState( 'networkidle' ); await page.locator( '#btn-ok' ).click(); - await page.waitForLoadState( 'networkidle' ); await expect( page @@ -196,8 +190,7 @@ test.describe( 'WooCommerce Shipping Settings - Add new shipping zone', () => { // this shipping zone already exists, don't create it } else { await page.goto( - 'wp-admin/admin.php?page=wc-settings&tab=shipping&zone_id=new', - { waitUntil: 'networkidle' } + 'wp-admin/admin.php?page=wc-settings&tab=shipping&zone_id=new' ); await page .getByPlaceholder( 'Zone name' ) @@ -209,7 +202,7 @@ test.describe( 'WooCommerce Shipping Settings - Add new shipping zone', () => { input.click(); input.fill( 'Canada' ); - await page.getByText( 'Canada' ).last().click(); + await page.getByLabel( 'Canada', { exact: true } ).click(); // Close dropdown await page.getByPlaceholder( 'Zone name' ).click(); @@ -222,10 +215,8 @@ test.describe( 'WooCommerce Shipping Settings - Add new shipping zone', () => { .getByRole( 'button', { name: 'Continue' } ) .last() .click(); - await page.waitForLoadState( 'networkidle' ); await page.locator( '#btn-ok' ).click(); - await page.waitForLoadState( 'networkidle' ); await expect( page @@ -240,7 +231,6 @@ test.describe( 'WooCommerce Shipping Settings - Add new shipping zone', () => { .click(); await page.getByLabel( 'Cost', { exact: true } ).fill( '10' ); await page.getByRole( 'button', { name: 'Save' } ).last().click(); - await page.waitForLoadState( 'networkidle' ); await page.goto( 'wp-admin/admin.php?page=wc-settings&tab=shipping' @@ -342,8 +332,7 @@ test.describe( 'WooCommerce Shipping Settings - Add new shipping zone', () => { // this shipping zone already exists, don't create it } else { await page.goto( - 'wp-admin/admin.php?page=wc-settings&tab=shipping&zone_id=new', - { waitUntil: 'networkidle' } + 'wp-admin/admin.php?page=wc-settings&tab=shipping&zone_id=new' ); await page.locator( '#zone_name' ).fill( shippingZoneNameFlatRate ); @@ -353,7 +342,7 @@ test.describe( 'WooCommerce Shipping Settings - Add new shipping zone', () => { input.click(); input.type( 'Canada' ); - await page.getByText( 'Canada' ).last().click(); + await page.getByLabel( 'Canada', { exact: true } ).click(); // Close dropdown await page.keyboard.press( 'Escape' ); @@ -366,10 +355,7 @@ test.describe( 'WooCommerce Shipping Settings - Add new shipping zone', () => { .last() .click(); - await page.waitForLoadState( 'networkidle' ); - await page.locator( '#btn-ok' ).click(); - await page.waitForLoadState( 'networkidle' ); await expect( page @@ -384,13 +370,17 @@ test.describe( 'WooCommerce Shipping Settings - Add new shipping zone', () => { .click(); await page.locator( '#woocommerce_flat_rate_cost' ).fill( '10' ); await page.locator( '#btn-ok' ).click(); - await page.waitForLoadState( 'networkidle' ); - await page.locator( 'text=Delete' ).waitFor(); + await expect( + page.getByRole( 'cell', { name: 'Edit | Delete', exact: true } ) + ).toBeVisible(); page.on( 'dialog', ( dialog ) => dialog.accept() ); - await page.locator( 'text=Delete' ).click(); + await page + .getByRole( 'cell', { name: 'Edit | Delete', exact: true } ) + .locator( 'text=Delete' ) + .click(); await expect( page.locator( '.wc-shipping-zone-method-blank-state' ) @@ -482,7 +472,6 @@ test.describe( 'Verifies shipping options from customer perspective', () => { await context.clearCookies(); await page.goto( `/shop/?add-to-cart=${ productId }` ); - await page.waitForLoadState( 'networkidle' ); } ); test.afterAll( async ( { baseURL } ) => { diff --git a/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-woocommerce-blocks.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-woocommerce-blocks.spec.js index 7ade737367f..05b21525efa 100644 --- a/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-woocommerce-blocks.spec.js +++ b/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-woocommerce-blocks.spec.js @@ -52,7 +52,14 @@ const test = baseTest.extend( { test.describe( 'Add WooCommerce Blocks Into Page', - { tag: [ '@gutenberg', '@services', '@skip-on-default-pressable' ] }, + { + tag: [ + '@gutenberg', + '@services', + '@skip-on-default-pressable', + '@skip-on-default-wpcom', + ], + }, () => { test.beforeAll( async ( { api } ) => { // add product attribute diff --git a/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-woocommerce-patterns.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-woocommerce-patterns.spec.js index ee6d56b0d9c..671788097b4 100644 --- a/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-woocommerce-patterns.spec.js +++ b/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-woocommerce-patterns.spec.js @@ -28,7 +28,14 @@ const test = baseTest.extend( { test.describe( 'Add WooCommerce Patterns Into Page', - { tag: [ '@gutenberg', '@services', '@skip-on-default-pressable' ] }, + { + tag: [ + '@gutenberg', + '@services', + '@skip-on-default-pressable', + '@skip-on-default-wpcom', + ], + }, () => { test( 'can insert WooCommerce patterns into page', async ( { page, @@ -86,7 +93,9 @@ test.describe( // check some elements from added patterns for ( let i = 1; i < wooPatterns.length; i++ ) { await expect( - page.getByText( `${ wooPatterns[ i ].button }` ) + page.getByRole( 'link', { + name: `${ wooPatterns[ i ].button }`, + } ) ).toBeVisible(); } } ); diff --git a/plugins/woocommerce/tests/e2e-pw/tests/merchant/customer-list.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/merchant/customer-list.spec.js index 1b8cb58846a..3a4501f9871 100644 --- a/plugins/woocommerce/tests/e2e-pw/tests/merchant/customer-list.spec.js +++ b/plugins/woocommerce/tests/e2e-pw/tests/merchant/customer-list.spec.js @@ -85,7 +85,7 @@ test.describe( 'Merchant > Customer List', { tag: '@services' }, () => { test( 'Merchant can view a list of all customers, filter and download', - { tag: '@skip-on-default-pressable' }, + { tag: [ '@skip-on-default-pressable', '@skip-on-default-wpcom' ] }, async ( { page, customers } ) => { await test.step( 'Go to the customers reports page', async () => { const responsePromise = page.waitForResponse( diff --git a/plugins/woocommerce/tests/e2e-pw/tests/merchant/customer-payment-page.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/merchant/customer-payment-page.spec.js index 652d819edd2..adc673add2b 100644 --- a/plugins/woocommerce/tests/e2e-pw/tests/merchant/customer-payment-page.spec.js +++ b/plugins/woocommerce/tests/e2e-pw/tests/merchant/customer-payment-page.spec.js @@ -110,6 +110,17 @@ test.describe( await test.step( 'Select payment method and pay for the order', async () => { // explicitly select the payment method await page.getByText( 'Direct bank transfer' ).click(); + + // Handle notice if present + await page.addLocatorHandler( + page.getByRole( 'link', { name: 'Dismiss' } ), + async () => { + await page + .getByRole( 'link', { name: 'Dismiss' } ) + .click(); + } + ); + // pay for the order await page .getByRole( 'button', { name: 'Pay for order' } ) diff --git a/plugins/woocommerce/tests/e2e-pw/tests/merchant/launch-your-store.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/merchant/launch-your-store.spec.js index cfef43102a9..4d40109a591 100644 --- a/plugins/woocommerce/tests/e2e-pw/tests/merchant/launch-your-store.spec.js +++ b/plugins/woocommerce/tests/e2e-pw/tests/merchant/launch-your-store.spec.js @@ -4,7 +4,7 @@ const wcApi = require( '@woocommerce/woocommerce-rest-api' ).default; test.describe( 'Launch Your Store - logged in', - { tag: [ '@gutenberg', '@services' ] }, + { tag: [ '@gutenberg', '@services', '@skip-on-default-wpcom' ] }, () => { test.use( { storageState: process.env.ADMINSTATE } ); From ce66b55bc5d5e982a54b8fa1cd94a5b52194bd5b Mon Sep 17 00:00:00 2001 From: Boro Sitnikovski Date: Wed, 18 Sep 2024 14:14:30 +0200 Subject: [PATCH 08/12] In app search improvements feature branch (#51413) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add search results count to the in-app marketplace (#51266) * Add searchResults to context * Use setSearchResults in Content * Add ribbons to the tabs * Changelog * Use setState as the function name * Only show ribbon counts when there's an active search * Refactor how 'setSearchResultsCount' is used (h/t @mcliwanow) * Don't populate initial search results * Unify css styling * Marketplace: bring back the loading state (#51342) * Marketplace: bring back the loading state * Add changefile(s) from automation for the following project(s): woocommerce --------- Co-authored-by: github-actions * Remove in-app marketplace Search results tab and unify results into existing tabs (#51297) * Remove search results component and any references to it * Persist current tab for searching, or default to extensions if tab is not set * Persist term when switching across tabs * Lint * When a search is initiated, fetch all categories to keep the tab counts up to date. The necessary filtering to display data to the current screen will be performed on the frontend. * Apply correct colors to the tabs, as per design * Beyond query.term, also rely on isLoading so that search result counts don't jump * Address an issue when the user searches for something that returns no results in the business services tab * Changelog * Addressed :) * Change key to category * Fix category filter being broken Whenever a category is requested, we need to do an additional request with the category param being the current category (overriding extensions/theme/business services). Ideally the backend API would make a distinction between type (theme/extension/business service) and category, but this hack should do for now. * Lint * Remove unused variables h/t @KokkieH * Lint * Revert "Lint" This reverts commit 0b2d2dca6de5087b3b0ae5599f8bb0114cebc4ab. * Actually fix lint without introducing infinite loop Reproducible at http://localhost:8080/wp-admin/admin.php?page=wc-admin&term=payments&tab=extensions&path=%2Fextensions&category=customer-service * Show category selector even if there are no results displayed * Update comment to be less misleading * Query isn't used here * Update Marketplace search component (#51313) * Update Search placeholder text * Replace search component with one from @wordpress/components * Make mobile search field font consistent with desktop * Add changefile(s) from automation for the following project(s): woocommerce * Handle import errors for SearchControl component --------- Co-authored-by: github-actions * Marketplace: update category selector (#51309) * Marketplace: update category selector Remove the dropdown on the desktop view and show all items, even if overflowing. Added helper buttons to scroll to the right to show more. * Add changefile(s) from automation for the following project(s): woocommerce * Marketplace: remove category sroll helpers from tabindex GitHub: https://github.com/woocommerce/woocommerce/pull/51309/files#r1758448638 * Marketplace: Remove selectedTab reference from product.tsx This is probably included due to the merge conflict * Marketplace: tweak category scroll button narrower --------- Co-authored-by: github-actions * Lint * Fix 2 lint errors * Fix another lint error (useMemo) h/t @KokkieH * Add load more button in-app (#51434) * Add additional fields returned by search API to marketplace types Ensure components have access to additional fields * Add LoadMoreButton component * Only render Load More button if there are additional pages of results * Fetch and display next page of results in Load More button is clicked * Simplify renderContent function to have less repetition - Hide load more button while fetching results * Improve loading of new products - Ensure keyboard focus goes to first new product after Load More is clicked * Add changefile(s) from automation for the following project(s): woocommerce * Add blank line to separate sections * Set category param based on current tab when loading more products * Improve busy-state screen reader text Co-authored-by: Boro Sitnikovski * Add missing dependency * Move getProductType() function to functions.tsx - Do not show load more button if isLoading state is true --------- Co-authored-by: github-actions Co-authored-by: Boro Sitnikovski * Rework the values used with `setSearchResultsCount` After https://github.com/Automattic/woocommerce.com/pull/21678/files we get a `totalProducts` so we can re-use that. Also remove setting the counts when paginating since we set them to the total. * Add search complete announcement h/t @KokkieH * Show update count only if greater than 0 h/t @andfinally * Switch to Extensions tab if on My subscriptions when searching * yoda --------- Co-authored-by: Cem Ünalan Co-authored-by: github-actions Co-authored-by: Herman --- .../category-selector/category-selector.scss | 26 +- .../category-selector/category-selector.tsx | 232 ++++++--- .../marketplace/components/constants.ts | 2 +- .../components/content/content.tsx | 443 +++++++++++++----- .../marketplace/components/header/header.scss | 15 +- .../load-more-button/load-more-button.tsx | 37 ++ .../components/product-card/product-card.tsx | 2 + .../product-list-content/no-results.tsx | 7 - .../components/product-list/types.ts | 2 + .../components/products/products.scss | 19 +- .../components/products/products.tsx | 71 +-- .../search-results/search-results.scss | 19 - .../search-results/search-results.tsx | 193 -------- .../marketplace/components/search/search.scss | 28 +- .../marketplace/components/search/search.tsx | 83 +--- .../marketplace/components/tabs/tabs.scss | 12 + .../marketplace/components/tabs/tabs.tsx | 130 ++--- .../contexts/marketplace-context.tsx | 33 +- .../client/marketplace/contexts/types.ts | 10 + .../client/marketplace/utils/functions.tsx | 25 +- .../client/marketplace/utils/tracking.ts | 10 - ...1309-update-21591-in-app-category-selector | 4 + ...update-wccom-21595-in-app-search-component | 4 + .../51342-fix-21596-search-loading-state | 4 + ...34-add-wccom-21568-in-app-load-more-button | 4 + .../changelog/refactor-wccom-21576-search-tab | 4 + .../tweak-21597-in-app-search-results-count | 4 + 27 files changed, 807 insertions(+), 616 deletions(-) create mode 100644 plugins/woocommerce-admin/client/marketplace/components/load-more-button/load-more-button.tsx delete mode 100644 plugins/woocommerce-admin/client/marketplace/components/search-results/search-results.scss delete mode 100644 plugins/woocommerce-admin/client/marketplace/components/search-results/search-results.tsx create mode 100644 plugins/woocommerce/changelog/51309-update-21591-in-app-category-selector create mode 100644 plugins/woocommerce/changelog/51313-update-wccom-21595-in-app-search-component create mode 100644 plugins/woocommerce/changelog/51342-fix-21596-search-loading-state create mode 100644 plugins/woocommerce/changelog/51434-add-wccom-21568-in-app-load-more-button create mode 100644 plugins/woocommerce/changelog/refactor-wccom-21576-search-tab create mode 100644 plugins/woocommerce/changelog/tweak-21597-in-app-search-results-count diff --git a/plugins/woocommerce-admin/client/marketplace/components/category-selector/category-selector.scss b/plugins/woocommerce-admin/client/marketplace/components/category-selector/category-selector.scss index d713efc1a80..952780f05e2 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/category-selector/category-selector.scss +++ b/plugins/woocommerce-admin/client/marketplace/components/category-selector/category-selector.scss @@ -1,13 +1,17 @@ @import "../../stylesheets/_variables.scss"; .woocommerce-marketplace__category-selector { + position: relative; display: flex; align-items: stretch; - margin: $grid-unit-20 0 0 0; + margin: 0; + overflow-x: auto; } .woocommerce-marketplace__category-item { cursor: pointer; + white-space: nowrap; + margin-bottom: 0; .components-dropdown { height: 100%; @@ -50,7 +54,6 @@ .woocommerce-marketplace__category-selector--full-width { display: none; - margin-top: $grid-unit-15; } @media screen and (max-width: $break-medium) { @@ -122,3 +125,22 @@ background-color: $gray-900; } } + +.woocommerce-marketplace__category-navigation-button { + border: none; + position: absolute; + top: 0; + bottom: 0; + height: 100%; + width: 50px; +} + +.woocommerce-marketplace__category-navigation-button--prev { + background: linear-gradient(to right, rgba(255, 255, 255, 1), rgba(255, 255, 255, 0)); + left: 0; +} + +.woocommerce-marketplace__category-navigation-button--next { + background: linear-gradient(to left, rgba(255, 255, 255, 1), rgba(255, 255, 255, 0)); + right: 0; +} diff --git a/plugins/woocommerce-admin/client/marketplace/components/category-selector/category-selector.tsx b/plugins/woocommerce-admin/client/marketplace/components/category-selector/category-selector.tsx index 07b298cde66..9625aef5862 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/category-selector/category-selector.tsx +++ b/plugins/woocommerce-admin/client/marketplace/components/category-selector/category-selector.tsx @@ -1,20 +1,21 @@ /** * External dependencies */ -import { useState, useEffect } from '@wordpress/element'; -import { __ } from '@wordpress/i18n'; +import { useState, useEffect, useRef } from '@wordpress/element'; import { useQuery } from '@woocommerce/navigation'; -import clsx from 'clsx'; +import { Icon } from '@wordpress/components'; +import { useDebounce } from '@wordpress/compose'; +import { __ } from '@wordpress/i18n'; /** * Internal dependencies */ import CategoryLink from './category-link'; -import CategoryDropdown from './category-dropdown'; import { Category, CategoryAPIItem } from './types'; import { fetchCategories } from '../../utils/functions'; -import './category-selector.scss'; import { ProductType } from '../product-list/types'; +import CategoryDropdown from './category-dropdown'; +import './category-selector.scss'; const ALL_CATEGORIES_SLUGS = { [ ProductType.extension ]: '_all', @@ -29,32 +30,21 @@ interface CategorySelectorProps { export default function CategorySelector( props: CategorySelectorProps ): JSX.Element { - const [ visibleItems, setVisibleItems ] = useState< Category[] >( [] ); - const [ dropdownItems, setDropdownItems ] = useState< Category[] >( [] ); const [ selected, setSelected ] = useState< Category >(); const [ isLoading, setIsLoading ] = useState( false ); + const [ categoriesToShow, setCategoriesToShow ] = useState< Category[] >( + [] + ); + const [ isOverflowing, setIsOverflowing ] = useState( false ); + const [ scrollPosition, setScrollPosition ] = useState< + 'start' | 'middle' | 'end' + >( 'start' ); + + const categorySelectorRef = useRef< HTMLUListElement >( null ); + const selectedCategoryRef = useRef< HTMLLIElement >( null ); const query = useQuery(); - useEffect( () => { - // If no category is selected, show All as selected - let categoryToSearch = ALL_CATEGORIES_SLUGS[ props.type ]; - - if ( query.category ) { - categoryToSearch = query.category; - } - - const allCategories = visibleItems.concat( dropdownItems ); - - const selectedCategory = allCategories.find( - ( category ) => category.slug === categoryToSearch - ); - - if ( selectedCategory ) { - setSelected( selectedCategory ); - } - }, [ query.category, props.type, visibleItems, dropdownItems ] ); - useEffect( () => { setIsLoading( true ); @@ -72,21 +62,125 @@ export default function CategorySelector( return category.slug !== '_featured'; } ); - // Split array into two from 7th item - const visibleCategoryItems = categories.slice( 0, 7 ); - const dropdownCategoryItems = categories.slice( 7 ); - - setVisibleItems( visibleCategoryItems ); - setDropdownItems( dropdownCategoryItems ); + setCategoriesToShow( categories ); } ) .catch( () => { - setVisibleItems( [] ); - setDropdownItems( [] ); + setCategoriesToShow( [] ); } ) .finally( () => { setIsLoading( false ); } ); - }, [ props.type ] ); + }, [ props.type, setCategoriesToShow ] ); + + useEffect( () => { + // If no category is selected, show All as selected + let categoryToSearch = ALL_CATEGORIES_SLUGS[ props.type ]; + + if ( query.category ) { + categoryToSearch = query.category; + } + + const selectedCategory = categoriesToShow.find( + ( category ) => category.slug === categoryToSearch + ); + + if ( selectedCategory ) { + setSelected( selectedCategory ); + } + }, [ query.category, props.type, categoriesToShow ] ); + + useEffect( () => { + if ( selectedCategoryRef.current ) { + selectedCategoryRef.current.scrollIntoView( { + block: 'nearest', + inline: 'center', + } ); + } + }, [ selected ] ); + + function checkOverflow() { + if ( + categorySelectorRef.current && + categorySelectorRef.current.parentElement?.scrollWidth + ) { + const isContentOverflowing = + categorySelectorRef.current.scrollWidth > + categorySelectorRef.current.parentElement.scrollWidth; + + setIsOverflowing( isContentOverflowing ); + } + } + + function checkScrollPosition() { + const ulElement = categorySelectorRef.current; + + if ( ! ulElement ) { + return; + } + + const { scrollLeft, scrollWidth, clientWidth } = ulElement; + + if ( scrollLeft < 10 ) { + setScrollPosition( 'start' ); + + return; + } + + if ( scrollLeft + clientWidth < scrollWidth ) { + setScrollPosition( 'middle' ); + + return; + } + + if ( scrollLeft + clientWidth === scrollWidth ) { + setScrollPosition( 'end' ); + } + } + + const debouncedCheckOverflow = useDebounce( checkOverflow, 300 ); + const debouncedScrollPosition = useDebounce( checkScrollPosition, 100 ); + + function scrollCategories( scrollAmount: number ) { + if ( categorySelectorRef.current ) { + categorySelectorRef.current.scrollTo( { + left: categorySelectorRef.current.scrollLeft + scrollAmount, + behavior: 'smooth', + } ); + } + } + + function scrollToNextCategories() { + scrollCategories( 200 ); + } + + function scrollToPrevCategories() { + scrollCategories( -200 ); + } + + useEffect( () => { + window.addEventListener( 'resize', debouncedCheckOverflow ); + + const ulElement = categorySelectorRef.current; + + if ( ulElement ) { + ulElement.addEventListener( 'scroll', debouncedScrollPosition ); + } + + return () => { + window.removeEventListener( 'resize', debouncedCheckOverflow ); + + if ( ulElement ) { + ulElement.removeEventListener( + 'scroll', + debouncedScrollPosition + ); + } + }; + }, [ debouncedCheckOverflow, debouncedScrollPosition ] ); + + useEffect( () => { + checkOverflow(); + }, [ categoriesToShow ] ); function mobileCategoryDropdownLabel() { const allCategoriesText = __( 'All Categories', 'woocommerce' ); @@ -102,16 +196,6 @@ export default function CategorySelector( return selected.label; } - function isSelectedInDropdown() { - if ( ! selected ) { - return false; - } - - return dropdownItems.find( - ( category ) => category.slug === selected.slug - ); - } - if ( isLoading ) { return ( <> @@ -131,50 +215,62 @@ export default function CategorySelector( return ( <> -
    - { visibleItems.map( ( category ) => ( +
      + { categoriesToShow.map( ( category ) => (
    • ) ) } -
    • - { dropdownItems.length > 0 && ( - - ) } -
    -
    + { isOverflowing && ( + <> + + + + ) } ); } diff --git a/plugins/woocommerce-admin/client/marketplace/components/constants.ts b/plugins/woocommerce-admin/client/marketplace/components/constants.ts index de76eded4e2..d9b7febe49a 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/constants.ts +++ b/plugins/woocommerce-admin/client/marketplace/components/constants.ts @@ -10,7 +10,7 @@ export const MARKETPLACE_SEARCH_API_PATH = '/wp-json/wccom-extensions/1.0/search'; export const MARKETPLACE_CATEGORY_API_PATH = '/wp-json/wccom-extensions/1.0/categories'; -export const MARKETPLACE_ITEMS_PER_PAGE = 60; +export const MARKETPLACE_ITEMS_PER_PAGE = 60; // This should match the number of results returned by the API export const MARKETPLACE_SEARCH_RESULTS_PER_PAGE = 8; export const MARKETPLACE_CART_PATH = MARKETPLACE_HOST + '/cart/'; export const MARKETPLACE_RENEW_SUBSCRIPTON_PATH = diff --git a/plugins/woocommerce-admin/client/marketplace/components/content/content.tsx b/plugins/woocommerce-admin/client/marketplace/components/content/content.tsx index 1a4679dc4c1..06f1a17825b 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/content/content.tsx +++ b/plugins/woocommerce-admin/client/marketplace/components/content/content.tsx @@ -1,22 +1,29 @@ /** * External dependencies */ -import { useContext, useEffect, useState } from '@wordpress/element'; +import { + useContext, + useEffect, + useState, + useCallback, +} from '@wordpress/element'; import { useQuery } from '@woocommerce/navigation'; +import { speak } from '@wordpress/a11y'; +import { __, sprintf } from '@wordpress/i18n'; /** * Internal dependencies */ import './content.scss'; -import { Product, ProductType, SearchResultType } from '../product-list/types'; +import { Product, ProductType } from '../product-list/types'; import { getAdminSetting } from '~/utils/admin-settings'; import Discover from '../discover/discover'; import Products from '../products/products'; -import SearchResults from '../search-results/search-results'; import MySubscriptions from '../my-subscriptions/my-subscriptions'; import { MarketplaceContext } from '../../contexts/marketplace-context'; -import { fetchSearchResults } from '../../utils/functions'; +import { fetchSearchResults, getProductType } from '../../utils/functions'; import { SubscriptionsContextProvider } from '../../contexts/subscriptions-context'; +import { SearchResultsCountType } from '../../contexts/types'; import { recordMarketplaceView, recordLegacyTabView, @@ -26,149 +33,350 @@ import Promotions from '../promotions/promotions'; import ConnectNotice from '~/marketplace/components/connect-notice/connect-notice'; import PluginInstallNotice from '../woo-update-manager-plugin/plugin-install-notice'; import SubscriptionsExpiredExpiringNotice from '~/marketplace/components/my-subscriptions/subscriptions-expired-expiring-notice'; +import LoadMoreButton from '../load-more-button/load-more-button'; export default function Content(): JSX.Element { const marketplaceContextValue = useContext( MarketplaceContext ); - const [ products, setProducts ] = useState< Product[] >( [] ); - const { setIsLoading, selectedTab, setHasBusinessServices } = - marketplaceContextValue; + const [ allProducts, setAllProducts ] = useState< Product[] >( [] ); + const [ filteredProducts, setFilteredProducts ] = useState< Product[] >( + [] + ); + const [ currentPage, setCurrentPage ] = useState( 1 ); + const [ totalPagesCategory, setTotalPagesCategory ] = useState( 1 ); + const [ totalPagesExtensions, setTotalPagesExtensions ] = useState( 1 ); + const [ totalPagesThemes, setTotalPagesThemes ] = useState( 1 ); + const [ totalPagesBusinessServices, setTotalPagesBusinessServices ] = + useState( 1 ); + const [ firstNewProductId, setFirstNewProductId ] = useState< number >( 0 ); + const [ isLoadingMore, setIsLoadingMore ] = useState( false ); + + const { + isLoading, + setIsLoading, + selectedTab, + setHasBusinessServices, + setSearchResultsCount, + } = marketplaceContextValue; const query = useQuery(); - // On initial load of the in-app marketplace, fetch extensions, themes and business services - // and check if there are any business services available on WCCOM - useEffect( () => { - const categories = [ '', 'themes', 'business-services' ]; - const abortControllers = categories.map( () => new AbortController() ); + const searchCompleteAnnouncement = ( count: number ): void => { + speak( + sprintf( + // translators: %d is the number of products found. + __( '%d products found', 'woocommerce' ), + count + ) + ); + }; - categories.forEach( ( category: string, index ) => { - const params = new URLSearchParams(); - if ( category !== '' ) { - params.append( 'category', category ); - } + const tagProductsWithType = ( + products: Product[], + type: ProductType + ): Product[] => { + return products.map( ( product ) => ( { + ...product, + type, + } ) ); + }; - const wccomSettings = getAdminSetting( 'wccomHelper', false ); - if ( wccomSettings.storeCountry ) { - params.append( 'country', wccomSettings.storeCountry ); - } - - fetchSearchResults( params, abortControllers[ index ].signal ).then( - ( productList ) => { - if ( category === 'business-services' ) { - setHasBusinessServices( productList.length > 0 ); - } - } - ); - return () => { - abortControllers.forEach( ( controller ) => { - controller.abort(); - } ); - }; - } ); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [] ); - - // Get the content for this screen - useEffect( () => { + const loadMoreProducts = useCallback( () => { + setIsLoadingMore( true ); + const params = new URLSearchParams(); const abortController = new AbortController(); - if ( - query.tab === undefined || - ( query.tab && - [ '', 'discover', 'my-subscriptions' ].includes( query.tab ) ) - ) { - return; + if ( query.category && query.category !== '_all' ) { + params.append( 'category', query.category ); } - setIsLoading( true ); - setProducts( [] ); - - const params = new URLSearchParams(); + if ( query.tab === 'themes' || query.tab === 'business-services' ) { + params.append( 'category', query.tab ); + } if ( query.term ) { params.append( 'term', query.term ); } - if ( query.category ) { - params.append( - 'category', - query.category === '_all' ? '' : query.category - ); - } else if ( query?.tab === 'themes' ) { - params.append( 'category', 'themes' ); - } else if ( query?.tab === 'business-services' ) { - params.append( 'category', 'business-services' ); - } else if ( query?.tab === 'search' ) { - params.append( 'category', 'extensions-themes-business-services' ); - } - const wccomSettings = getAdminSetting( 'wccomHelper', false ); if ( wccomSettings.storeCountry ) { params.append( 'country', wccomSettings.storeCountry ); } + params.append( 'page', ( currentPage + 1 ).toString() ); + fetchSearchResults( params, abortController.signal ) .then( ( productList ) => { - setProducts( productList ); + setAllProducts( ( prevProducts ) => { + const flattenedPrevProducts = Array.isArray( + prevProducts[ 0 ] + ) + ? prevProducts.flat() + : prevProducts; + + const newProducts = productList.products.filter( + ( newProduct ) => + ! flattenedPrevProducts.some( + ( prevProduct ) => + prevProduct.id === newProduct.id + ) + ); + + if ( newProducts.length > 0 ) { + setFirstNewProductId( newProducts[ 0 ].id ?? 0 ); + } + + const combinedProducts = [ + ...flattenedPrevProducts, + ...newProducts, + ]; + + return combinedProducts; + } ); + + speak( __( 'More products loaded', 'woocommerce' ) ); + setCurrentPage( ( prevPage ) => prevPage + 1 ); + setIsLoadingMore( false ); } ) .catch( () => { - setProducts( [] ); + speak( __( 'Error loading more products', 'woocommerce' ) ); } ) .finally( () => { - // we are recording both the new and legacy events here for now - // they're separate methods to make it easier to remove the legacy one later - const marketplaceViewProps = { - view: query?.tab, - search_term: query?.term, - product_type: query?.section, - category: query?.category, - }; - - recordMarketplaceView( marketplaceViewProps ); - recordLegacyTabView( marketplaceViewProps ); - setIsLoading( false ); + setIsLoadingMore( false ); } ); + return () => { abortController.abort(); }; }, [ + currentPage, + query.category, + query.term, + query.tab, + setIsLoadingMore, + ] ); + + useEffect( () => { + // if it's a paginated request, don't use this effect + if ( currentPage > 1 ) { + return; + } + + const categories: Array< { + category: keyof SearchResultsCountType; + type: ProductType; + } > = [ + { category: 'extensions', type: ProductType.extension }, + { category: 'themes', type: ProductType.theme }, + { + category: 'business-services', + type: ProductType.businessService, + }, + ]; + const abortControllers = categories.map( () => new AbortController() ); + + setIsLoading( true ); + setAllProducts( [] ); + + // If query.category is present and not '_all', only fetch that category + if ( query.category && query.category !== '_all' ) { + const params = new URLSearchParams(); + + params.append( 'category', query.category ); + + if ( query.term ) { + params.append( 'term', query.term ); + } + + const wccomSettings = getAdminSetting( 'wccomHelper', false ); + if ( wccomSettings.storeCountry ) { + params.append( 'country', wccomSettings.storeCountry ); + } + + fetchSearchResults( params, abortControllers[ 0 ].signal ) + .then( ( productList ) => { + setAllProducts( productList.products ); + setTotalPagesCategory( productList.totalPages ); + setSearchResultsCount( { + [ query.tab ]: productList.totalProducts, + } ); + + searchCompleteAnnouncement( productList.totalProducts ); + } ) + .catch( () => { + setAllProducts( [] ); + } ) + .finally( () => { + setIsLoading( false ); + } ); + } else { + // Fetch all tabs when query.term or query.category changes + Promise.all( + categories.map( ( { category, type }, index ) => { + const params = new URLSearchParams(); + if ( category !== 'extensions' ) { + params.append( 'category', category ); + } + if ( query.term ) { + params.append( 'term', query.term ); + } + + const wccomSettings = getAdminSetting( + 'wccomHelper', + false + ); + if ( wccomSettings.storeCountry ) { + params.append( 'country', wccomSettings.storeCountry ); + } + + return fetchSearchResults( + params, + abortControllers[ index ].signal + ).then( ( productList ) => { + const typedProducts = tagProductsWithType( + productList.products, + type + ); + if ( category === 'business-services' ) { + setHasBusinessServices( typedProducts.length > 0 ); + } + return { + products: typedProducts, + totalPages: productList.totalPages, + totalProducts: productList.totalProducts, + type, + }; + } ); + } ) + ) + .then( ( results ) => { + const combinedProducts = results.flatMap( + ( result ) => result.products + ); + + setAllProducts( combinedProducts ); + + setSearchResultsCount( { + extensions: results.find( + ( i ) => i.type === 'extension' + )?.totalProducts, + themes: results.find( ( i ) => i.type === 'theme' ) + ?.totalProducts, + 'business-services': results.find( + ( i ) => i.type === 'business-service' + )?.totalProducts, + } ); + + results.forEach( ( result ) => { + switch ( result.type ) { + case ProductType.extension: + setTotalPagesExtensions( result.totalPages ); + break; + case ProductType.theme: + setTotalPagesThemes( result.totalPages ); + break; + case ProductType.businessService: + setTotalPagesBusinessServices( + result.totalPages + ); + break; + } + } ); + + searchCompleteAnnouncement( + results.reduce( ( acc, curr ) => { + return acc + curr.totalProducts; + }, 0 ) + ); + } ) + .catch( () => { + setAllProducts( [] ); + } ) + .finally( () => { + setIsLoading( false ); + } ); + } + + return () => { + abortControllers.forEach( ( controller ) => { + controller.abort(); + } ); + }; + }, [ + query.tab, query.term, query.category, - query?.tab, + setHasBusinessServices, setIsLoading, - query?.section, + setSearchResultsCount, + currentPage, ] ); + // Filter the products based on the selected tab + useEffect( () => { + let filtered: Product[] | null; + switch ( selectedTab ) { + case 'extensions': + filtered = allProducts.filter( + ( p ) => p.type === ProductType.extension + ); + break; + case 'themes': + filtered = allProducts.filter( + ( p ) => p.type === ProductType.theme + ); + break; + case 'business-services': + filtered = allProducts.filter( + ( p ) => p.type === ProductType.businessService + ); + break; + default: + filtered = []; + } + setFilteredProducts( filtered ); + }, [ selectedTab, allProducts ] ); + + // Record tab view events when the query changes + useEffect( () => { + const marketplaceViewProps = { + view: query?.tab, + search_term: query?.term, + product_type: query?.section, + category: query?.category, + }; + recordMarketplaceView( marketplaceViewProps ); + recordLegacyTabView( marketplaceViewProps ); + }, [ query?.tab, query?.term, query?.section, query?.category ] ); + + // Reset current page when tab, term, or category changes + useEffect( () => { + setCurrentPage( 1 ); + setFirstNewProductId( 0 ); + }, [ selectedTab, query?.category, query?.term ] ); + + // Maintain product focus for accessibility + useEffect( () => { + if ( firstNewProductId ) { + setTimeout( () => { + const firstNewProduct = document.getElementById( + `product-${ firstNewProductId }` + ); + if ( firstNewProduct ) { + firstNewProduct.focus(); + } + }, 0 ); + } + }, [ firstNewProductId ] ); + const renderContent = (): JSX.Element => { switch ( selectedTab ) { case 'extensions': - return ( - - ); case 'themes': - return ( - - ); case 'business-services': return ( - ); - case 'search': - return ( - ); case 'discover': @@ -184,10 +392,29 @@ export default function Content(): JSX.Element { } }; + const shouldShowLoadMoreButton = () => { + if ( ! query.category || query.category === '_all' ) { + // Check against total pages for the selected tab + switch ( selectedTab ) { + case 'extensions': + return currentPage < totalPagesExtensions; + case 'themes': + return currentPage < totalPagesThemes; + case 'business-services': + return currentPage < totalPagesBusinessServices; + default: + return false; + } + } else { + // Check against totalPagesCategory for specific category + return currentPage < totalPagesCategory; + } + }; + return (
    - + { selectedTab !== 'business-services' && selectedTab !== 'my-subscriptions' && } { selectedTab !== 'business-services' && } @@ -197,11 +424,15 @@ export default function Content(): JSX.Element { { selectedTab !== 'business-services' && ( ) } - { selectedTab !== 'business-services' && ( - - ) } { renderContent() } + { ! isLoading && shouldShowLoadMoreButton() && ( + + ) }
    ); } diff --git a/plugins/woocommerce-admin/client/marketplace/components/header/header.scss b/plugins/woocommerce-admin/client/marketplace/components/header/header.scss index 11616f62a7f..f4e14f0ff23 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/header/header.scss +++ b/plugins/woocommerce-admin/client/marketplace/components/header/header.scss @@ -5,6 +5,7 @@ background: #fff; border-bottom: 1px solid $gutenberg-gray-300; display: grid; + gap: $medium-gap; grid-template: "mktpl-title mktpl-search mktpl-meta" 60px "mktpl-tabs mktpl-tabs mktpl-tabs" auto / 1fr 320px 36px; padding: 0 $content-spacing-large; @@ -73,17 +74,3 @@ padding: 0 $content-spacing-small; } } - -.woocommerce-marketplace__search { - margin-right: $medium-gap; - margin-top: 10px; - - input[type="search"] { - all: unset; - flex-grow: 1; - } - - @media (width <= $breakpoint-medium) { - margin: $content-spacing-small; - } -} diff --git a/plugins/woocommerce-admin/client/marketplace/components/load-more-button/load-more-button.tsx b/plugins/woocommerce-admin/client/marketplace/components/load-more-button/load-more-button.tsx new file mode 100644 index 00000000000..07fb2ea6c48 --- /dev/null +++ b/plugins/woocommerce-admin/client/marketplace/components/load-more-button/load-more-button.tsx @@ -0,0 +1,37 @@ +/** + * External dependencies + */ +import { Button } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { speak } from '@wordpress/a11y'; +import { queueRecordEvent } from '@woocommerce/tracks'; + +interface LoadMoreProps { + onLoadMore: () => void; + isBusy: boolean; + disabled: boolean; +} + +export default function LoadMoreButton( props: LoadMoreProps ) { + const { onLoadMore, isBusy, disabled } = props; + function handleClick() { + queueRecordEvent( 'marketplace_load_more_button_clicked', {} ); + onLoadMore(); + } + + if ( isBusy ) { + speak( __( 'Loading more products', 'woocommerce' ) ); + } + + return ( + + ); +} diff --git a/plugins/woocommerce-admin/client/marketplace/components/product-card/product-card.tsx b/plugins/woocommerce-admin/client/marketplace/components/product-card/product-card.tsx index 79585e83189..00ba4a2d0e7 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/product-card/product-card.tsx +++ b/plugins/woocommerce-admin/client/marketplace/components/product-card/product-card.tsx @@ -191,6 +191,8 @@ function ProductCard( props: ProductCardProps ): JSX.Element { return ( diff --git a/plugins/woocommerce-admin/client/marketplace/components/product-list-content/no-results.tsx b/plugins/woocommerce-admin/client/marketplace/components/product-list-content/no-results.tsx index da81e0d8059..9ecdd54c98a 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/product-list-content/no-results.tsx +++ b/plugins/woocommerce-admin/client/marketplace/components/product-list-content/no-results.tsx @@ -3,7 +3,6 @@ */ import { __ } from '@wordpress/i18n'; import { useEffect, useState } from '@wordpress/element'; -import { useQuery } from '@woocommerce/navigation'; /** * Internal dependencies @@ -22,8 +21,6 @@ export default function NoResults( props: { } ): JSX.Element { const [ productGroups, setProductGroups ] = useState< ProductGroup[] >(); const [ isLoading, setIsLoading ] = useState( false ); - const query = useQuery(); - const showCategorySelector = query.tab === 'search' && query.section; const productGroupsForSearchType = { [ SearchResultType.all ]: [ 'most-popular', @@ -123,10 +120,6 @@ export default function NoResults( props: { } function categorySelector() { - if ( ! showCategorySelector ) { - return <>; - } - if ( props.type === SearchResultType.all ) { return <>; } diff --git a/plugins/woocommerce-admin/client/marketplace/components/product-list/types.ts b/plugins/woocommerce-admin/client/marketplace/components/product-list/types.ts index 5008cef836a..9ee660ddb08 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/product-list/types.ts +++ b/plugins/woocommerce-admin/client/marketplace/components/product-list/types.ts @@ -1,5 +1,7 @@ export type SearchAPIJSONType = { products: Array< SearchAPIProductType >; + total_pages: number; + total_products: number; }; export type SearchAPIProductType = { diff --git a/plugins/woocommerce-admin/client/marketplace/components/products/products.scss b/plugins/woocommerce-admin/client/marketplace/components/products/products.scss index 2d92bfd5259..4fb5f89218c 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/products/products.scss +++ b/plugins/woocommerce-admin/client/marketplace/components/products/products.scss @@ -9,10 +9,21 @@ } } + .woocommerce-marketplace__sub-header { display: flex; - - .woocommerce-marketplace__customize-your-store-button { - margin: 16px 0 6px auto; - } + align-items: center; + justify-content: space-between; + gap: 32px; } + +.woocommerce-marketplace__sub-header__categories { + flex: 1; + overflow-x: auto; + position: relative; +} + +.woocommerce-marketplace__customize-your-store-button { + flex-shrink: 0; +} + diff --git a/plugins/woocommerce-admin/client/marketplace/components/products/products.tsx b/plugins/woocommerce-admin/client/marketplace/components/products/products.tsx index 8ecd211e4d2..a078e991198 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/products/products.tsx +++ b/plugins/woocommerce-admin/client/marketplace/components/products/products.tsx @@ -1,7 +1,7 @@ /** * External dependencies */ -import { __, _n, sprintf } from '@wordpress/i18n'; +import { __ } from '@wordpress/i18n'; import { createInterpolateElement, useContext, @@ -24,7 +24,6 @@ import ProductListContent from '../product-list-content/product-list-content'; import ProductLoader from '../product-loader/product-loader'; import NoResults from '../product-list-content/no-results'; import { Product, ProductType, SearchResultType } from '../product-list/types'; -import { MARKETPLACE_ITEMS_PER_PAGE } from '../constants'; import { ADMIN_URL } from '~/utils/admin-settings'; import { ThemeSwitchWarningModal } from '~/customize-store/intro/warning-modals'; @@ -54,12 +53,10 @@ const LABELS = { export default function Products( props: ProductsProps ) { const marketplaceContextValue = useContext( MarketplaceContext ); - const { isLoading, selectedTab } = marketplaceContextValue; + const { isLoading } = marketplaceContextValue; const label = LABELS[ props.type ].label; - const singularLabel = LABELS[ props.type ].singularLabel; const query = useQuery(); const category = query?.category; - const perPage = props.perPage ?? MARKETPLACE_ITEMS_PER_PAGE; interface Theme { stylesheet?: string; } @@ -94,42 +91,30 @@ export default function Products( props: ProductsProps ) { } // Store the total number of products before we slice it later. - const productTotalCount = props.products?.length ?? 0; - const products = props.products?.slice( 0, perPage ) ?? []; - - let title = sprintf( - // translators: %s: plural item type (e.g. extensions, themes) - __( '0 %s found', 'woocommerce' ), - label - ); - - if ( productTotalCount > 0 ) { - title = sprintf( - // translators: %1$s: number of items, %2$s: singular item label, %3$s: plural item label - _n( '%1$s %2$s', '%1$s %3$s', productTotalCount, 'woocommerce' ), - productTotalCount, - singularLabel, - label - ); - } + const products = props.products ?? []; const labelForClassName = label === 'business services' ? 'business-services' : label; const baseContainerClass = 'woocommerce-marketplace__search-'; - const baseProductListTitleClass = 'product-list-title--'; const containerClassName = clsx( baseContainerClass + labelForClassName ); - const productListTitleClassName = clsx( - 'woocommerce-marketplace__product-list-title', - baseContainerClass + baseProductListTitleClass + labelForClassName, - { 'is-loading': isLoading } - ); const viewAllButonClassName = clsx( 'woocommerce-marketplace__view-all-button', baseContainerClass + 'button-' + labelForClassName ); + if ( isLoading ) { + return ( + <> + { props.categorySelector && ( + + ) } + + + ); + } + if ( products.length === 0 ) { let type = SearchResultType.all; @@ -154,28 +139,14 @@ export default function Products( props: ProductsProps ) { : '' ); - if ( isLoading ) { - return ( - <> - { props.categorySelector && ( - - ) } - - - ); - } - return (
    - { selectedTab === 'search' && ( -

    - { isLoading ? ' ' : title } -

    - ) } -
    - { props.categorySelector && ( - - ) } +
    + { isModalOpen && ( product.type === ProductType.extension - ); - const themeList = props.products.filter( - ( product ) => product.type === ProductType.theme - ); - const businessServiceList = props.products.filter( - ( product ) => product.type === ProductType.businessService - ); - - const hasExtensions = extensionList.length > 0; - const hasThemes = themeList.length > 0; - const hasBusinessServices = businessServiceList.length > 0; - const hasOnlyExtensions = - hasExtensions && ! hasThemes && ! hasBusinessServices; - const hasOnlyThemes = hasThemes && ! hasExtensions && ! hasBusinessServices; - const hasOnlyBusinessServices = - hasBusinessServices && ! hasExtensions && ! hasThemes; - - const marketplaceContextValue = useContext( MarketplaceContext ); - const { isLoading, hasBusinessServices: canShowBusinessServices } = - marketplaceContextValue; - - const query = useQuery(); - const showCategorySelector = query.section ? true : false; - const searchTerm = query.term ? query.term : ''; - - type Overrides = { - categorySelector?: boolean; - showAllButton?: boolean; - perPage?: number; - }; - - function productsComponent( - products: Product[], - type: ProductType, - overrides: Overrides = {} - ) { - return ( - - ); - } - - function extensionsComponent( overrides: Overrides = {} ) { - return productsComponent( - extensionList, - ProductType.extension, - overrides - ); - } - - function themesComponent( overrides: Overrides = {} ) { - return productsComponent( themeList, ProductType.theme, overrides ); - } - - function businessServicesComponent( overrides: Overrides = {} ) { - return productsComponent( - businessServiceList, - ProductType.businessService, - overrides - ); - } - - const content = () => { - if ( query?.section === SearchResultType.extension ) { - return extensionsComponent( { showAllButton: false } ); - } - - if ( query?.section === SearchResultType.theme ) { - return themesComponent( { showAllButton: false } ); - } - - if ( query?.section === SearchResultType.businessService ) { - return businessServicesComponent( { showAllButton: false } ); - } - - // Components can handle their isLoading state. So we can put all three on the page. - if ( isLoading ) { - return ( - <> - { extensionsComponent() } - { themesComponent() } - { businessServicesComponent() } - - ); - } - - // If we did finish loading items, and there are no results, show the no results component. - if ( - ! isLoading && - ! hasExtensions && - ! hasThemes && - ! hasBusinessServices - ) { - return ( - - ); - } - - // If we're done loading, we can put these components on the page. - return ( - <> - { hasExtensions - ? extensionsComponent( { - categorySelector: hasOnlyExtensions || undefined, - showAllButton: hasOnlyExtensions - ? false - : undefined, - perPage: hasOnlyExtensions - ? MARKETPLACE_ITEMS_PER_PAGE - : MARKETPLACE_SEARCH_RESULTS_PER_PAGE, - } ) - : null } - { hasThemes - ? themesComponent( { - categorySelector: hasOnlyThemes || undefined, - showAllButton: hasOnlyThemes ? false : undefined, - perPage: hasOnlyThemes - ? MARKETPLACE_ITEMS_PER_PAGE - : MARKETPLACE_SEARCH_RESULTS_PER_PAGE, - } ) - : null } - { hasBusinessServices - ? businessServicesComponent( { - categorySelector: - hasOnlyBusinessServices || undefined, - showAllButton: hasOnlyBusinessServices - ? false - : undefined, - perPage: hasOnlyBusinessServices - ? MARKETPLACE_ITEMS_PER_PAGE - : MARKETPLACE_SEARCH_RESULTS_PER_PAGE, - } ) - : null } - - ); - }; - - return ( -
    - { content() } -
    - ); -} diff --git a/plugins/woocommerce-admin/client/marketplace/components/search/search.scss b/plugins/woocommerce-admin/client/marketplace/components/search/search.scss index e6725b7ca2d..0fb878f214f 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/search/search.scss +++ b/plugins/woocommerce-admin/client/marketplace/components/search/search.scss @@ -2,29 +2,15 @@ .woocommerce-marketplace__search { grid-area: mktpl-search; - background: $gutenberg-gray-100; - border: 1.5px solid transparent; - border-radius: 2px; - display: flex; - height: 40px; - padding: 4px 8px 4px 12px; - - input[type="search"] { - all: unset; - flex-grow: 1; - } - - &:focus-within { - background: #fff; - border-color: var(--wp-admin-theme-color, #3858e9); - } + margin-top: 15px; + width: 320px; @media (width <= $breakpoint-medium) { margin: $grid-unit-20 $grid-unit-20 $grid-unit-10 $grid-unit-20; + width: calc(100% - $grid-unit-20 * 2); + + .components-input-control__input { + font-size: 13px !important; + } } } - -.woocommerce-marketplace__search-button { - all: unset; - cursor: pointer; -} diff --git a/plugins/woocommerce-admin/client/marketplace/components/search/search.tsx b/plugins/woocommerce-admin/client/marketplace/components/search/search.tsx index e03582ca4e8..f9572c7c7fa 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/search/search.tsx +++ b/plugins/woocommerce-admin/client/marketplace/components/search/search.tsx @@ -2,26 +2,20 @@ * External dependencies */ import { __ } from '@wordpress/i18n'; -import { Icon, search } from '@wordpress/icons'; -import { useContext, useEffect, useState } from '@wordpress/element'; +import { useEffect, useState } from '@wordpress/element'; import { navigateTo, getNewPath, useQuery } from '@woocommerce/navigation'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +// eslint-disable-next-line @woocommerce/dependency-group +import { SearchControl } from '@wordpress/components'; +// The @ts-ignore is needed because the SearchControl types are not exported from the @wordpress/components package, +// even though the component itself is. This is likely due to an older version of the package being used. /** * Internal dependencies */ import './search.scss'; import { MARKETPLACE_PATH } from '../constants'; -import { MarketplaceContext } from '../../contexts/marketplace-context'; - -const searchPlaceholder = __( - 'Search for extensions, themes, and business services', - 'woocommerce' -); - -const searchPlaceholderNoBusinessServices = __( - 'Search for extensions and themes', - 'woocommerce' -); /** * Search component. @@ -30,14 +24,10 @@ const searchPlaceholderNoBusinessServices = __( */ function Search(): JSX.Element { const [ searchTerm, setSearchTerm ] = useState( '' ); - const { hasBusinessServices } = useContext( MarketplaceContext ); + const searchPlaceholder = __( 'Search Marketplace', 'woocommerce' ); const query = useQuery(); - const placeholder = hasBusinessServices - ? searchPlaceholder - : searchPlaceholderNoBusinessServices; - useEffect( () => { if ( query.term ) { setSearchTerm( query.term ); @@ -46,21 +36,16 @@ function Search(): JSX.Element { } }, [ query.term ] ); - useEffect( () => { - if ( query.tab !== 'search' ) { - setSearchTerm( '' ); - } - }, [ query.tab ] ); - const runSearch = () => { - const term = searchTerm.trim(); + const newQuery: { term?: string; tab?: string } = query; - const newQuery: { term?: string; tab?: string } = {}; - if ( term !== '' ) { - newQuery.term = term; - newQuery.tab = 'search'; + // If we're on 'Discover' or 'My subscriptions' when a search is initiated, move to the extensions tab + if ( ! newQuery.tab || newQuery.tab === 'my-subscriptions' ) { + newQuery.tab = 'extensions'; } + newQuery.term = searchTerm.trim(); + // When the search term changes, we reset the query string on purpose. navigateTo( { url: getNewPath( newQuery, MARKETPLACE_PATH, {} ), @@ -69,12 +54,6 @@ function Search(): JSX.Element { return []; }; - const handleInputChange = ( - event: React.ChangeEvent< HTMLInputElement > - ) => { - setSearchTerm( event.target.value ); - }; - const handleKeyUp = ( event: { key: string } ) => { if ( event.key === 'Enter' ) { runSearch(); @@ -86,32 +65,14 @@ function Search(): JSX.Element { }; return ( -
    - - - -
    + ); } diff --git a/plugins/woocommerce-admin/client/marketplace/components/tabs/tabs.scss b/plugins/woocommerce-admin/client/marketplace/components/tabs/tabs.scss index 008fe4e1b84..3a8e7c152e6 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/tabs/tabs.scss +++ b/plugins/woocommerce-admin/client/marketplace/components/tabs/tabs.scss @@ -45,6 +45,18 @@ text-align: center; z-index: 26; } + + &__update-count-extensions, + &__update-count-themes, + &__update-count-business-services { + background-color: $gutenberg-gray-300; + color: $gutenberg-gray-700; + + &.is-active { + background-color: #000; + color: #fff; + } + } } @media (width <= $breakpoint-medium) { diff --git a/plugins/woocommerce-admin/client/marketplace/components/tabs/tabs.tsx b/plugins/woocommerce-admin/client/marketplace/components/tabs/tabs.tsx index 1f852a2b982..052a06df435 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/tabs/tabs.tsx +++ b/plugins/woocommerce-admin/client/marketplace/components/tabs/tabs.tsx @@ -2,7 +2,7 @@ * External dependencies */ import { __ } from '@wordpress/i18n'; -import { useContext, useEffect, useState } from '@wordpress/element'; +import { useContext, useEffect, useState, useMemo } from '@wordpress/element'; import { Button } from '@wordpress/components'; import clsx from 'clsx'; import { getNewPath, navigateTo, useQuery } from '@woocommerce/navigation'; @@ -35,63 +35,26 @@ interface Tabs { const wccomSettings = getAdminSetting( 'wccomHelper', {} ); const wooUpdateCount = wccomSettings?.wooUpdateCount ?? 0; -const tabs: Tabs = { - search: { - name: 'search', - title: __( 'Search results', 'woocommerce' ), - showUpdateCount: false, - updateCount: 0, - }, - discover: { - name: 'discover', - title: __( 'Discover', 'woocommerce' ), - showUpdateCount: false, - updateCount: 0, - }, - extensions: { - name: 'extensions', - title: __( 'Extensions', 'woocommerce' ), - showUpdateCount: false, - updateCount: 0, - }, - themes: { - name: 'themes', - title: __( 'Themes', 'woocommerce' ), - showUpdateCount: false, - updateCount: 0, - }, - 'business-services': { - name: 'business-services', - title: __( 'Business services', 'woocommerce' ), - showUpdateCount: false, - updateCount: 0, - }, - 'my-subscriptions': { - name: 'my-subscriptions', - title: __( 'My subscriptions', 'woocommerce' ), - showUpdateCount: true, - updateCount: wooUpdateCount, - }, -}; - -const setUrlTabParam = ( tabKey: string ) => { +const setUrlTabParam = ( tabKey: string, query: Record< string, string > ) => { + const term = query.term ? { term: query.term.trim() } : {}; navigateTo( { url: getNewPath( { tab: tabKey === DEFAULT_TAB_KEY ? undefined : tabKey }, MARKETPLACE_PATH, - {} + term ), } ); }; -const getVisibleTabs = ( selectedTab: string, hasBusinessServices = false ) => { +const getVisibleTabs = ( + selectedTab: string, + hasBusinessServices = false, + tabs: Tabs +) => { if ( selectedTab === '' ) { return tabs; } const currentVisibleTabs = { ...tabs }; - if ( selectedTab !== 'search' ) { - delete currentVisibleTabs.search; - } if ( ! hasBusinessServices ) { delete currentVisibleTabs[ 'business-services' ]; } @@ -101,7 +64,9 @@ const getVisibleTabs = ( selectedTab: string, hasBusinessServices = false ) => { const renderTabs = ( marketplaceContextValue: MarketplaceContextType, - visibleTabs: Tabs + visibleTabs: Tabs, + tabs: Tabs, + query: Record< string, string > ) => { const { selectedTab, setSelectedTab } = marketplaceContextValue; @@ -110,7 +75,7 @@ const renderTabs = ( return; } setSelectedTab( tabKey ); - setUrlTabParam( tabKey ); + setUrlTabParam( tabKey, query ); }; const tabContent = []; @@ -143,7 +108,15 @@ const renderTabs = ( { tabs[ tabKey ]?.title } { tabs[ tabKey ]?.showUpdateCount && tabs[ tabKey ]?.updateCount > 0 && ( - + { tabs[ tabKey ]?.updateCount } ) } @@ -157,23 +130,70 @@ const renderTabs = ( const Tabs = ( props: TabsProps ): JSX.Element => { const { additionalClassNames } = props; const marketplaceContextValue = useContext( MarketplaceContext ); - const { selectedTab, setSelectedTab, hasBusinessServices } = + const { selectedTab, isLoading, setSelectedTab, hasBusinessServices } = marketplaceContextValue; - const [ visibleTabs, setVisibleTabs ] = useState( getVisibleTabs( '' ) ); + const { searchResultsCount } = marketplaceContextValue; const query: Record< string, string > = useQuery(); + const tabs: Tabs = useMemo( + () => ( { + discover: { + name: 'discover', + title: __( 'Discover', 'woocommerce' ), + showUpdateCount: false, + updateCount: 0, + }, + extensions: { + name: 'extensions', + title: __( 'Extensions', 'woocommerce' ), + showUpdateCount: !! query.term && ! isLoading, + updateCount: searchResultsCount.extensions, + }, + themes: { + name: 'themes', + title: __( 'Themes', 'woocommerce' ), + showUpdateCount: !! query.term && ! isLoading, + updateCount: searchResultsCount.themes, + }, + 'business-services': { + name: 'business-services', + title: __( 'Business services', 'woocommerce' ), + showUpdateCount: !! query.term && ! isLoading, + updateCount: searchResultsCount[ 'business-services' ], + }, + 'my-subscriptions': { + name: 'my-subscriptions', + title: __( 'My subscriptions', 'woocommerce' ), + showUpdateCount: true, + updateCount: wooUpdateCount, + }, + } ), + [ query, isLoading, searchResultsCount ] + ); + + const [ visibleTabs, setVisibleTabs ] = useState( + getVisibleTabs( '', false, tabs ) + ); + useEffect( () => { if ( query?.tab && tabs[ query.tab ] ) { setSelectedTab( query.tab ); } else if ( Object.keys( query ).length > 0 ) { setSelectedTab( DEFAULT_TAB_KEY ); } - }, [ query, setSelectedTab ] ); + }, [ query, setSelectedTab, tabs ] ); useEffect( () => { - setVisibleTabs( getVisibleTabs( selectedTab, hasBusinessServices ) ); - }, [ selectedTab, hasBusinessServices ] ); + setVisibleTabs( + getVisibleTabs( selectedTab, hasBusinessServices, tabs ) + ); + + if ( selectedTab === 'business-services' && ! hasBusinessServices ) { + setUrlTabParam( 'extensions', query ); + } + }, [ selectedTab, hasBusinessServices, query, tabs ] ); + return ( ); }; diff --git a/plugins/woocommerce-admin/client/marketplace/contexts/marketplace-context.tsx b/plugins/woocommerce-admin/client/marketplace/contexts/marketplace-context.tsx index 53dd1bb5789..0631bf5725e 100644 --- a/plugins/woocommerce-admin/client/marketplace/contexts/marketplace-context.tsx +++ b/plugins/woocommerce-admin/client/marketplace/contexts/marketplace-context.tsx @@ -1,12 +1,17 @@ /** * External dependencies */ -import { useState, useEffect, createContext } from '@wordpress/element'; +import { + useState, + useEffect, + useCallback, + createContext, +} from '@wordpress/element'; /** * Internal dependencies */ -import { MarketplaceContextType } from './types'; +import { SearchResultsCountType, MarketplaceContextType } from './types'; import { getAdminSetting } from '../../utils/admin-settings'; export const MarketplaceContext = createContext< MarketplaceContextType >( { @@ -18,6 +23,12 @@ export const MarketplaceContext = createContext< MarketplaceContextType >( { addInstalledProduct: () => {}, hasBusinessServices: false, setHasBusinessServices: () => {}, + searchResultsCount: { + extensions: 0, + themes: 0, + 'business-services': 0, + }, + setSearchResultsCount: () => {}, } ); export function MarketplaceContextProvider( props: { @@ -29,6 +40,22 @@ export function MarketplaceContextProvider( props: { [] ); const [ hasBusinessServices, setHasBusinessServices ] = useState( false ); + const [ searchResultsCount, setSearchResultsCountState ] = + useState< SearchResultsCountType >( { + extensions: 0, + themes: 0, + 'business-services': 0, + } ); + + const setSearchResultsCount = useCallback( + ( updatedCounts: Partial< SearchResultsCountType > ) => { + setSearchResultsCountState( ( prev ) => ( { + ...prev, + ...updatedCounts, + } ) ); + }, + [] + ); /** * Knowing installed products will help us to determine which products @@ -59,6 +86,8 @@ export function MarketplaceContextProvider( props: { addInstalledProduct, hasBusinessServices, setHasBusinessServices, + searchResultsCount, + setSearchResultsCount, }; return ( diff --git a/plugins/woocommerce-admin/client/marketplace/contexts/types.ts b/plugins/woocommerce-admin/client/marketplace/contexts/types.ts index 969ce88d2e1..3240729871e 100644 --- a/plugins/woocommerce-admin/client/marketplace/contexts/types.ts +++ b/plugins/woocommerce-admin/client/marketplace/contexts/types.ts @@ -8,6 +8,12 @@ import { Options } from '@wordpress/notices'; */ import { Subscription } from '../components/my-subscriptions/types'; +export interface SearchResultsCountType { + extensions: number; + themes: number; + 'business-services': number; +} + export type MarketplaceContextType = { isLoading: boolean; setIsLoading: ( isLoading: boolean ) => void; @@ -17,6 +23,10 @@ export type MarketplaceContextType = { addInstalledProduct: ( slug: string ) => void; hasBusinessServices: boolean; setHasBusinessServices: ( hasBusinessServices: boolean ) => void; + searchResultsCount: SearchResultsCountType; + setSearchResultsCount: ( + updatedCounts: Partial< SearchResultsCountType > + ) => void; }; export type SubscriptionsContextType = { diff --git a/plugins/woocommerce-admin/client/marketplace/utils/functions.tsx b/plugins/woocommerce-admin/client/marketplace/utils/functions.tsx index d7fbc982c15..a063490eefc 100644 --- a/plugins/woocommerce-admin/client/marketplace/utils/functions.tsx +++ b/plugins/woocommerce-admin/client/marketplace/utils/functions.tsx @@ -107,7 +107,11 @@ async function fetchJsonWithCache( async function fetchSearchResults( params: URLSearchParams, abortSignal?: AbortSignal -): Promise< Product[] > { +): Promise< { + products: Product[]; + totalPages: number; + totalProducts: number; +} > { const url = MARKETPLACE_HOST + MARKETPLACE_SEARCH_API_PATH + @@ -151,9 +155,12 @@ async function fetchSearchResults( }; } ); - resolve( products ); + const totalPages = ( json as SearchAPIJSONType ).total_pages; + const totalProducts = ( json as SearchAPIJSONType ) + .total_products; + resolve( { products, totalPages, totalProducts } ); } ) - .catch( () => reject ); + .catch( reject ); } ); } @@ -174,6 +181,17 @@ async function fetchDiscoverPageData(): Promise< ProductGroup[] > { } } +function getProductType( tab: string ): ProductType { + switch ( tab ) { + case 'themes': + return ProductType.theme; + case 'business-services': + return ProductType.businessService; + default: + return ProductType.extension; + } +} + function fetchCategories( type: ProductType ): Promise< CategoryAPIItem[] > { const url = new URL( MARKETPLACE_HOST + MARKETPLACE_CATEGORY_API_PATH ); @@ -478,6 +496,7 @@ export { fetchCategories, fetchDiscoverPageData, fetchSearchResults, + getProductType, fetchSubscriptions, refreshSubscriptions, getInstallUrl, diff --git a/plugins/woocommerce-admin/client/marketplace/utils/tracking.ts b/plugins/woocommerce-admin/client/marketplace/utils/tracking.ts index 6729c824c55..8b08027770e 100644 --- a/plugins/woocommerce-admin/client/marketplace/utils/tracking.ts +++ b/plugins/woocommerce-admin/client/marketplace/utils/tracking.ts @@ -42,11 +42,6 @@ function recordMarketplaceView( props: MarketplaceViewProps ) { eventProps.category = '_all'; } - // User clicks the `View All` button on search results - if ( view && view === 'search' && product_type && ! category ) { - eventProps.category = '_all'; - } - recordEvent( 'marketplace_view', eventProps ); } @@ -80,11 +75,6 @@ function recordLegacyTabView( props: MarketplaceViewProps ) { case 'themes': oldEventProps.section = 'themes'; break; - case 'search': - oldEventName = 'extensions_view_search'; - oldEventProps.section = view; - oldEventProps.search_term = search_term || ''; - break; case 'my-subscriptions': oldEventName = 'subscriptions_view'; oldEventProps.section = 'helper'; diff --git a/plugins/woocommerce/changelog/51309-update-21591-in-app-category-selector b/plugins/woocommerce/changelog/51309-update-21591-in-app-category-selector new file mode 100644 index 00000000000..504e3d4d2d2 --- /dev/null +++ b/plugins/woocommerce/changelog/51309-update-21591-in-app-category-selector @@ -0,0 +1,4 @@ +Significance: patch +Type: update + +Update In-App Marketplace category selector \ No newline at end of file diff --git a/plugins/woocommerce/changelog/51313-update-wccom-21595-in-app-search-component b/plugins/woocommerce/changelog/51313-update-wccom-21595-in-app-search-component new file mode 100644 index 00000000000..42a06d9bf86 --- /dev/null +++ b/plugins/woocommerce/changelog/51313-update-wccom-21595-in-app-search-component @@ -0,0 +1,4 @@ +Significance: minor +Type: update + +Replace marketplace search component with SearchControl from @wordpress/components \ No newline at end of file diff --git a/plugins/woocommerce/changelog/51342-fix-21596-search-loading-state b/plugins/woocommerce/changelog/51342-fix-21596-search-loading-state new file mode 100644 index 00000000000..a55cd0e6601 --- /dev/null +++ b/plugins/woocommerce/changelog/51342-fix-21596-search-loading-state @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Fix the loading state for the In-App Marketplace search \ No newline at end of file diff --git a/plugins/woocommerce/changelog/51434-add-wccom-21568-in-app-load-more-button b/plugins/woocommerce/changelog/51434-add-wccom-21568-in-app-load-more-button new file mode 100644 index 00000000000..50f6c905990 --- /dev/null +++ b/plugins/woocommerce/changelog/51434-add-wccom-21568-in-app-load-more-button @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Added a Load More button to product lists on the Extensions page, to request additional search results from WooCommerce.com. \ No newline at end of file diff --git a/plugins/woocommerce/changelog/refactor-wccom-21576-search-tab b/plugins/woocommerce/changelog/refactor-wccom-21576-search-tab new file mode 100644 index 00000000000..ba8046f3451 --- /dev/null +++ b/plugins/woocommerce/changelog/refactor-wccom-21576-search-tab @@ -0,0 +1,4 @@ +Significance: minor +Type: update + +Change the way search results are displayed in the in-app marketplace diff --git a/plugins/woocommerce/changelog/tweak-21597-in-app-search-results-count b/plugins/woocommerce/changelog/tweak-21597-in-app-search-results-count new file mode 100644 index 00000000000..b0edda219c3 --- /dev/null +++ b/plugins/woocommerce/changelog/tweak-21597-in-app-search-results-count @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add search result counts to the in-app marketplace header tabs (Extensions area) From 610af536d120f75495d2af664f94bc29d154f51a Mon Sep 17 00:00:00 2001 From: DAnn2012 Date: Wed, 18 Sep 2024 14:15:51 +0200 Subject: [PATCH 09/12] Fix typo (adding-a-custom-field-to-variable-products.md) (#50331) Update adding-a-custom-field-to-variable-products.md Co-authored-by: Seghir Nadir --- .../adding-a-custom-field-to-variable-products.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/building-a-woo-store/adding-a-custom-field-to-variable-products.md b/docs/building-a-woo-store/adding-a-custom-field-to-variable-products.md index 3b7a53c9fe8..d5cbef24096 100644 --- a/docs/building-a-woo-store/adding-a-custom-field-to-variable-products.md +++ b/docs/building-a-woo-store/adding-a-custom-field-to-variable-products.md @@ -254,4 +254,4 @@ Displaying the variation in the front store works a bit differently for variable ## How to find hooks? -Everyone will have their own preferred way, but for me, the quickest way is to look in the WooCommere plugin code. The code for each data section can be found in `/woocommerce/includes/admin/meta-boxes/views`. To view how the inventory section is handled check the `html-product-data-inventory.php` file, and for variations take a look at `html-variation-admin.php`. +Everyone will have their own preferred way, but for me, the quickest way is to look in the WooCommerce plugin code. The code for each data section can be found in `/woocommerce/includes/admin/meta-boxes/views`. To view how the inventory section is handled check the `html-product-data-inventory.php` file, and for variations take a look at `html-variation-admin.php`. From 8042fcdae351b7dd5e8004c3ffad63cdbc7926e1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 18 Sep 2024 19:32:28 +0700 Subject: [PATCH 10/12] Delete changelog files based on PR 51456 (#51496) Delete changelog files for 51456 Co-authored-by: WooCommerce Bot --- plugins/woocommerce/changelog/pr-51456 | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 plugins/woocommerce/changelog/pr-51456 diff --git a/plugins/woocommerce/changelog/pr-51456 b/plugins/woocommerce/changelog/pr-51456 deleted file mode 100644 index 9d71edd5fbf..00000000000 --- a/plugins/woocommerce/changelog/pr-51456 +++ /dev/null @@ -1,4 +0,0 @@ -Significance: patch -Type: fix - -Fix invalid path error in product importer in Windows From 13f5eee40b522307370e7aa8d8bd1de5414d6751 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 18 Sep 2024 19:33:24 +0700 Subject: [PATCH 11/12] Delete changelog files based on PR 51441 (#51497) Delete changelog files for 51441 Co-authored-by: WooCommerce Bot --- plugins/woocommerce/changelog/revert-low-stock-notification | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 plugins/woocommerce/changelog/revert-low-stock-notification diff --git a/plugins/woocommerce/changelog/revert-low-stock-notification b/plugins/woocommerce/changelog/revert-low-stock-notification deleted file mode 100644 index 3ec370d906f..00000000000 --- a/plugins/woocommerce/changelog/revert-low-stock-notification +++ /dev/null @@ -1,4 +0,0 @@ -Significance: patch -Type: fix - -Revert - changes related to low stock product notification From df37ccf8c596b42840af895ff9458418de353b8e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 18 Sep 2024 19:34:07 +0700 Subject: [PATCH 12/12] Delete changelog files based on PR 51449 (#51477) Delete changelog files for 51449 Co-authored-by: WooCommerce Bot --- plugins/woocommerce/changelog/51449-dev-harden-added-to-cart | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 plugins/woocommerce/changelog/51449-dev-harden-added-to-cart diff --git a/plugins/woocommerce/changelog/51449-dev-harden-added-to-cart b/plugins/woocommerce/changelog/51449-dev-harden-added-to-cart deleted file mode 100644 index 99351de4130..00000000000 --- a/plugins/woocommerce/changelog/51449-dev-harden-added-to-cart +++ /dev/null @@ -1,4 +0,0 @@ -Significance: patch -Type: fix - -Fix bug where manually triggering `added_to_cart` event without a button element caused an Exception. \ No newline at end of file