diff --git a/plugins/woocommerce/changelog/add-rest-api-products-featured-image b/plugins/woocommerce/changelog/add-rest-api-products-featured-image new file mode 100644 index 00000000000..0592a983c8f --- /dev/null +++ b/plugins/woocommerce/changelog/add-rest-api-products-featured-image @@ -0,0 +1,4 @@ +Significance: minor +Type: enhancement + +Add `featured` field to the images array for Product REST API diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php index 1a61edd6c4d..ce0ba6bb95d 100644 --- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php +++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php @@ -45,9 +45,11 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller { protected function get_images( $product ) { $images = array(); $attachment_ids = array(); + $featured_id = null; // Add featured image. if ( $product->get_image_id() ) { + $featured_id = $product->get_image_id(); $attachment_ids[] = $product->get_image_id(); } @@ -68,6 +70,7 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller { $images[] = array( 'id' => (int) $attachment_id, + 'featured' => (int) $featured_id === (int) $attachment_id, 'date_created' => wc_rest_prepare_date_response( $attachment_post->post_date, false ), 'date_created_gmt' => wc_rest_prepare_date_response( strtotime( $attachment_post->post_date_gmt ) ), 'date_modified' => wc_rest_prepare_date_response( $attachment_post->post_modified, false ), @@ -305,6 +308,31 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller { $images = is_array( $images ) ? array_filter( $images ) : array(); if ( ! empty( $images ) ) { + $featured_image_index = 0; + $featured_image_count = 0; + + // Collect featured field usage. + foreach ( $images as $index => $image ) { + if ( isset( $image['featured'] ) && $image['featured'] ) { + $featured_image_index = $index; + $featured_image_count++; + } + } + + // Handle multiple featured images. + if ( $featured_image_count > 1 ) { + throw new WC_REST_Exception( + 'woocommerce_rest_product_featured_image_count', + __( 'Only one featured image is allowed.', 'woocommerce' ), + 400 + ); + } + + // If no featured image is set, and the first image explicitly set to false do not set featured at all. + if ( 0 === $featured_image_count && isset( $images[0]['featured'] ) && false === $images[0]['featured'] ) { + $featured_image_index = null; + } + $gallery = array(); foreach ( $images as $index => $image ) { @@ -329,9 +357,7 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller { throw new WC_REST_Exception( 'woocommerce_product_invalid_image_id', sprintf( __( '#%s is an invalid image ID.', 'woocommerce' ), $attachment_id ), 400 ); } - $featured_image = $product->get_image_id(); - - if ( 0 === $index ) { + if ( $featured_image_index === $index ) { $product->set_image_id( $attachment_id ); } else { $gallery[] = $attachment_id; @@ -1253,6 +1279,12 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller { 'type' => 'integer', 'context' => array( 'view', 'edit' ), ), + 'featured' => array( + 'description' => __( 'Featured image.', 'woocommerce' ), + 'type' => 'boolean', + 'default' => false, + 'context' => array( 'view', 'edit' ), + ), 'date_created' => array( 'description' => __( "The date the image was created, in the site's timezone.", 'woocommerce' ), 'type' => 'date-time', diff --git a/plugins/woocommerce/tests/api-core-tests/tests/products/products-crud.test.js b/plugins/woocommerce/tests/api-core-tests/tests/products/products-crud.test.js index 9ab7a6163e1..00fcd5b58d7 100644 --- a/plugins/woocommerce/tests/api-core-tests/tests/products/products-crud.test.js +++ b/plugins/woocommerce/tests/api-core-tests/tests/products/products-crud.test.js @@ -265,7 +265,7 @@ test.describe('Products API tests: CRUD', () => { expect(responseJSON.type).toEqual('select'); expect(responseJSON.order_by).toEqual('name'); // the below has_archives test is currently not working as expected - // an issue (https://github.com/woocommerce/woocommerce/issues/34991) + // an issue (https://github.com/woocommerce/woocommerce/issues/34991) // has been raised and this test can be // updated as appropriate after triage // expect(responseJSON.has_archives).toEqual(true); @@ -913,7 +913,6 @@ test.describe('Products API tests: CRUD', () => { }); }); - test.describe('Product tags tests: CRUD', () => { let productTagId; @@ -1074,6 +1073,235 @@ test.describe('Products API tests: CRUD', () => { }); }); + test.describe( 'Product images tests: CRUD', () => { + let productId; + let images; + + test( 'can add product with an image', async ( { request } ) => { + const response = await request.post( 'wp-json/wc/v3/products', { + data: { + images: [ { src: 'https://cldup.com/6L9h56D9Bw.jpg' } ], + }, + } ); + const responseJSON = await response.json(); + + expect( response.status() ).toEqual( 201 ); + expect( responseJSON.images ).toHaveLength( 1 ); + expect( responseJSON.images[ 0 ].name ).toContain( '6L9h56D9Bw' ); + expect( responseJSON.images[ 0 ].featured ).toBeTruthy(); + expect( responseJSON.images[ 0 ].alt ).toEqual( '' ); + expect( responseJSON.images[ 0 ].src ).toContain( '6L9h56D9Bw' ); + + // Cleanup: Delete the used product + await request.delete( + `wp-json/wc/v3/products/${ responseJSON.id }`, + { + data: { + force: true, + }, + } + ); + } ); + + test( 'can add product with multiple images (backward compatible)', async ( { + request, + } ) => { + const response = await request.post( 'wp-json/wc/v3/products', { + data: { + images: [ + { src: 'https://cldup.com/6L9h56D9Bw.jpg' }, + { src: 'https://cldup.com/Dr1Bczxq4q.png' }, + ], + }, + } ); + const responseJSON = await response.json(); + productId = responseJSON.id; + images = responseJSON.images.map( ( image ) => image.id ); + + expect( response.status() ).toEqual( 201 ); + expect( responseJSON.images ).toHaveLength( 2 ); + expect( responseJSON.images[ 0 ].name ).toContain( '6L9h56D9Bw' ); + expect( responseJSON.images[ 0 ].featured ).toBeTruthy(); + expect( responseJSON.images[ 1 ].name ).toContain( 'Dr1Bczxq4q' ); + expect( responseJSON.images[ 1 ].featured ).toBeFalsy(); + } ); + + test( 'can add product with multiple images (explicit featured)', async ( { + request, + } ) => { + const response = await request.post( 'wp-json/wc/v3/products', { + data: { + images: [ + { + src: 'https://cldup.com/6L9h56D9Bw.jpg', + featured: false, + }, + { + src: 'https://cldup.com/Dr1Bczxq4q.png', + featured: true, + }, + ], + }, + } ); + const responseJSON = await response.json(); + + expect( response.status() ).toEqual( 201 ); + expect( responseJSON.images ).toHaveLength( 2 ); + // When retrieving, the featured image is always the first one. + expect( responseJSON.images[ 0 ].name ).toContain( 'Dr1Bczxq4q' ); + expect( responseJSON.images[ 0 ].featured ).toBeTruthy(); + expect( responseJSON.images[ 1 ].name ).toContain( '6L9h56D9Bw' ); + expect( responseJSON.images[ 1 ].featured ).toBeFalsy(); + + // Cleanup: Delete the used product + await request.delete( + `wp-json/wc/v3/products/${ responseJSON.id }`, + { + data: { + force: true, + }, + } + ); + } ); + + test( 'can add product with multiple images (no featured)', async ( { + request, + } ) => { + const response = await request.post( 'wp-json/wc/v3/products', { + data: { + images: [ + { + src: 'https://cldup.com/6L9h56D9Bw.jpg', + featured: false, + }, + { + src: 'https://cldup.com/Dr1Bczxq4q.png', + featured: false, + }, + ], + }, + } ); + const responseJSON = await response.json(); + + expect( response.status() ).toEqual( 201 ); + expect( responseJSON.images ).toHaveLength( 2 ); + expect( responseJSON.images[ 0 ].name ).toContain( '6L9h56D9Bw' ); + expect( responseJSON.images[ 0 ].alt ).toEqual( '' ); + expect( responseJSON.images[ 0 ].featured ).toBeFalsy(); + expect( responseJSON.images[ 1 ].name ).toContain( 'Dr1Bczxq4q' ); + expect( responseJSON.images[ 1 ].alt ).toEqual( '' ); + expect( responseJSON.images[ 1 ].featured ).toBeFalsy(); + + // Cleanup: Delete the used product + await request.delete( + `wp-json/wc/v3/products/${ responseJSON.id }`, + { + data: { + force: true, + }, + } + ); + } ); + + test( 'cannot add product with multiple images (all featured)', async ( { + request, + } ) => { + const response = await request.post( 'wp-json/wc/v3/products', { + data: { + images: [ + { + src: 'https://cldup.com/6L9h56D9Bw.jpg', + featured: true, + }, + { + src: 'https://cldup.com/Dr1Bczxq4q.png', + featured: true, + }, + ], + }, + } ); + const responseJSON = await response.json(); + + expect( response.status() ).toEqual( 400 ); + expect( responseJSON.code ).toEqual( + 'woocommerce_rest_product_featured_image_count' + ); + expect( responseJSON.message ).toEqual( + 'Only one featured image is allowed.' + ); + expect( responseJSON.data ).toEqual( { status: 400 } ); + } ); + + test( 'can retrieve product images', async ( { request } ) => { + const response = await request.get( + `wp-json/wc/v3/products/${ productId }` + ); + const responseJSON = await response.json(); + + expect( response.status() ).toEqual( 200 ); + expect( responseJSON.images ).toHaveLength( 2 ); + expect( responseJSON.images[ 0 ].id ).toEqual( images[ 0 ] ); + expect( responseJSON.images[ 0 ].name ).toContain( '6L9h56D9Bw' ); + expect( responseJSON.images[ 0 ].alt ).toEqual( '' ); + expect( responseJSON.images[ 0 ].featured ).toBeTruthy(); + } ); + + test( 'can update a product images', async ( { request } ) => { + // call API to update a product + const response = await request.put( + `wp-json/wc/v3/products/${ productId }`, + { + data: { + images: [ + { id: images[ 0 ], featured: false }, + { id: images[ 1 ], featured: true }, + ], + }, + } + ); + const responseJSON = await response.json(); + + expect( response.status() ).toEqual( 200 ); + expect( responseJSON.images ).toHaveLength( 2 ); + // When retrieving, the featured image is always the first one. + expect( responseJSON.images[ 0 ].id ).toEqual( images[ 1 ] ); + expect( responseJSON.images[ 0 ].name ).toContain( 'Dr1Bczxq4q' ); + expect( responseJSON.images[ 0 ].alt ).toEqual( '' ); + expect( responseJSON.images[ 0 ].featured ).toBeTruthy(); + expect( responseJSON.images[ 1 ].id ).toEqual( images[ 0 ] ); + expect( responseJSON.images[ 1 ].name ).toContain( '6L9h56D9Bw' ); + expect( responseJSON.images[ 1 ].alt ).toEqual( '' ); + expect( responseJSON.images[ 1 ].featured ).toBeFalsy(); + } ); + + test( 'can remove an image from a product', async ( { request } ) => { + // Delete the product attribute. + const response = await request.put( + `wp-json/wc/v3/products/${ productId }`, + { + data: { + images: [ { id: images[ 1 ] } ], + }, + } + ); + const responseJSON = await response.json(); + + expect( response.status() ).toEqual( 200 ); + expect( responseJSON.images ).toHaveLength( 1 ); + expect( responseJSON.images[ 0 ].id ).toEqual( images[ 1 ] ); + expect( responseJSON.images[ 0 ].name ).toContain( 'Dr1Bczxq4q' ); + expect( responseJSON.images[ 0 ].alt ).toEqual( '' ); + expect( responseJSON.images[ 0 ].featured ).toBeTruthy(); + + // Cleanup: Delete the used product + await request.delete( `wp-json/wc/v3/products/${ productId }`, { + data: { + force: true, + }, + } ); + } ); + } ); + test('can add a virtual product', async ({ request }) => { diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php index c4419b92499..71569ba483b 100644 --- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php +++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php @@ -204,6 +204,84 @@ class WC_REST_Products_Controller_Tests extends WC_REST_Unit_Test_Case { } } + /** + * Test that featured field exists under images in product argument. + */ + public function test_products_args_includes_featured_field() { + $request = new WP_REST_Request( 'OPTIONS', '/wc/v3/products' ); + $request->set_param( '_fields', 'endpoints.1.args.images.items.properties' ); + $response = $this->server->dispatch( $request ); + $image_arg = $response->get_data()['endpoints'][1]['args']['images']['items']['properties']; + $this->assertArrayHasKey( 'featured', $image_arg ); + $this->assertEquals( + array( + 'description' => 'Featured image.', + 'type' => 'boolean', + 'default' => false, + 'context' => + array( + 0 => 'view', + 1 => 'edit', + ), + ), + $image_arg['featured'] + ); + } + + /** + * Test that featured field exists under images in product schema. + */ + public function test_products_schema_includes_featured_field() { + $request = new WP_REST_Request( 'OPTIONS', '/wc/v3/products' ); + $request->set_param( '_fields', 'schema.properties.images.items.properties' ); + $response = $this->server->dispatch( $request ); + $image_schema = $response->get_data()['schema']['properties']['images']['items']['properties']; + $this->assertArrayHasKey( 'featured', $image_schema ); + $this->assertEquals( + array( + 'description' => 'Featured image.', + 'type' => 'boolean', + 'default' => false, + 'context' => + array( + 0 => 'view', + 1 => 'edit', + ), + ), + $image_schema['featured'] + ); + } + + /** + * Test that feature field is returned inside images. + */ + public function test_products_get_images_array_contains_featured() { + global $wpdb; + + $product = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\ProductHelper::create_simple_product(); + $gallery_url = media_sideload_image( 'https://cldup.com/6L9h56D9Bw.jpg', $product->get_id(), 'gallery', 'src' ); + $this->assertNotWPError( $gallery_url ); + $gallery_id = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE guid = %s", $gallery_url ) ); + + $featured_url = media_sideload_image( 'https://cldup.com/Dr1Bczxq4q.png', $product->get_id(), 'featured', 'src' ); + $this->assertNotWPError( $featured_url ); + $featured_id = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE guid = %s", $featured_url ) ); + + $product->set_image_id( $featured_id[0] ); + $product->set_gallery_image_ids( $gallery_id[0] ); + $product->save(); + + $request = new WP_REST_Request( 'GET', '/wc/v3/products/' . $product->get_id() ); + $request->set_param( '_fields', 'images' ); + $response = $this->server->dispatch( $request ); + $images_array = $response->get_data()['images']; + $this->assertEquals( 200, $response->get_status() ); + $this->assertTrue( $images_array[0]['featured'] ); + $this->assertEquals( 'featured', $images_array[0]['name'] ); + $this->assertFalse( $images_array[1]['featured'] ); + $this->assertEquals( 'gallery', $images_array[1]['name'] ); + } + /** * Test that the `search` parameter does partial matching in the product name, but not the SKU. *