diff --git a/includes/class-core-schema-filters.php b/includes/class-core-schema-filters.php index 4e621ece5..38b8af2d0 100644 --- a/includes/class-core-schema-filters.php +++ b/includes/class-core-schema-filters.php @@ -129,6 +129,7 @@ public static function register_post_types( $args, $post_type ) { $args['graphql_interfaces'] = [ 'ContentNode' ]; $args['graphql_register_root_field'] = false; $args['graphql_register_root_connection'] = false; + $args['graphql_exclude_mutations'] = [ 'create', 'delete', 'update' ]; $args['graphql_resolve_type'] = static function ( $value ) { $type_registry = \WPGraphQL::get_type_registry(); $possible_types = WooGraphQL::get_enabled_product_types(); diff --git a/includes/class-type-registry.php b/includes/class-type-registry.php index c679b54ad..c96d08ee1 100644 --- a/includes/class-type-registry.php +++ b/includes/class-type-registry.php @@ -66,6 +66,10 @@ public function init() { Type\WPInputObject\Collection_Stats_Query_Input::register(); Type\WPInputObject\Collection_Stats_Where_Args::register(); Type\WPInputObject\Product_Attribute_Filter_Input::register(); + Type\WPInputObject\Product_Attributes_Input::register(); + Type\WPInputObject\Product_Dimensions_Input::register(); + Type\WPInputObject\Product_Download_Input::register(); + Type\WPInputObject\Product_Image_Input::register(); /** * Interfaces. @@ -111,6 +115,8 @@ public function init() { Type\WPObject\Payment_Token_Types::register(); Type\WPObject\Country_State_Type::register(); Type\WPObject\Collection_Stats_Type::register(); + Type\WPObject\Product_Attribute_Object_Type::register(); + Type\WPObject\Product_Attribute_Term_Object_Type::register(); /** * Object fields. @@ -148,32 +154,44 @@ public function init() { /** * Mutations. */ - Mutation\Customer_Register::register_mutation(); - Mutation\Customer_Update::register_mutation(); + Mutation\Cart_Add_Fee::register_mutation(); Mutation\Cart_Add_Item::register_mutation(); Mutation\Cart_Add_Items::register_mutation(); - Mutation\Cart_Update_Item_Quantities::register_mutation(); - Mutation\Cart_Remove_Items::register_mutation(); - Mutation\Cart_Restore_Items::register_mutation(); - Mutation\Cart_Empty::register_mutation(); Mutation\Cart_Apply_Coupon::register_mutation(); + Mutation\Cart_Empty::register_mutation(); + Mutation\Cart_Fill::register_mutation(); + Mutation\Cart_Remove_Items::register_mutation(); Mutation\Cart_Remove_Coupons::register_mutation(); - Mutation\Cart_Add_Fee::register_mutation(); + Mutation\Cart_Restore_Items::register_mutation(); + Mutation\Cart_Update_Item_Quantities::register_mutation(); Mutation\Cart_Update_Shipping_Method::register_mutation(); - Mutation\Cart_Fill::register_mutation(); + Mutation\Checkout::register_mutation(); + Mutation\Coupon_Create::register_mutation(); + Mutation\Coupon_Update::register_mutation(); + Mutation\Coupon_Delete::register_mutation(); + Mutation\Customer_Register::register_mutation(); + Mutation\Customer_Update::register_mutation(); Mutation\Order_Create::register_mutation(); Mutation\Order_Update::register_mutation(); Mutation\Order_Delete::register_mutation(); Mutation\Order_Delete_Items::register_mutation(); - Mutation\Checkout::register_mutation(); + Mutation\Payment_Method_Delete::register_mutation(); + Mutation\Payment_Method_Set_Default::register_mutation(); + Mutation\Product_Attribute_Create::register_mutation(); + Mutation\Product_Attribute_Update::register_mutation(); + Mutation\Product_Attribute_Delete::register_mutation(); + Mutation\Product_Attribute_Term_Create::register_mutation(); + Mutation\Product_Attribute_Term_Update::register_mutation(); + Mutation\Product_Attribute_Term_Delete::register_mutation(); + Mutation\Product_Create::register_mutation(); + Mutation\Product_Update::register_mutation(); + Mutation\Product_Delete::register_mutation(); + Mutation\Product_Variation_Create::register_mutation(); + Mutation\Product_Variation_Update::register_mutation(); + Mutation\Product_Variation_Delete::register_mutation(); Mutation\Review_Write::register_mutation(); Mutation\Review_Update::register_mutation(); Mutation\Review_Delete_Restore::register_mutation(); - Mutation\Coupon_Create::register_mutation(); - Mutation\Coupon_Update::register_mutation(); - Mutation\Coupon_Delete::register_mutation(); - Mutation\Payment_Method_Delete::register_mutation(); - Mutation\Payment_Method_Set_Default::register_mutation(); Mutation\Update_Session::register_mutation(); } } diff --git a/includes/class-wp-graphql-woocommerce.php b/includes/class-wp-graphql-woocommerce.php index bd99096c3..01c2e9b05 100644 --- a/includes/class-wp-graphql-woocommerce.php +++ b/includes/class-wp-graphql-woocommerce.php @@ -205,6 +205,7 @@ private function includes() { require $include_directory_path . 'data/mutation/class-coupon-mutation.php'; require $include_directory_path . 'data/mutation/class-customer-mutation.php'; require $include_directory_path . 'data/mutation/class-order-mutation.php'; + require $include_directory_path . 'data/mutation/class-product-mutation.php'; // Include factory class file. require $include_directory_path . 'data/class-factory.php'; @@ -282,6 +283,8 @@ private function includes() { require $include_directory_path . 'type/object/class-payment-token-types.php'; require $include_directory_path . 'type/object/class-country-state-type.php'; require $include_directory_path . 'type/object/class-collection-stats-type.php'; + require $include_directory_path . 'type/object/class-product-attribute-object-type.php'; + require $include_directory_path . 'type/object/class-product-attribute-term-object-type.php'; // Include input type class files. require $include_directory_path . 'type/input/class-cart-item-input.php'; @@ -300,6 +303,10 @@ private function includes() { require $include_directory_path . 'type/input/class-collection-stats-query-input.php'; require $include_directory_path . 'type/input/class-collection-stats-where-args.php'; require $include_directory_path . 'type/input/class-product-attribute-filter-input.php'; + require $include_directory_path . 'type/input/class-product-attributes-input.php'; + require $include_directory_path . 'type/input/class-product-dimensions-input.php'; + require $include_directory_path . 'type/input/class-product-download-input.php'; + require $include_directory_path . 'type/input/class-product-image-input.php'; // Include mutation type class files. require $include_directory_path . 'mutation/class-cart-add-fee.php'; @@ -323,11 +330,23 @@ private function includes() { require $include_directory_path . 'mutation/class-order-delete-items.php'; require $include_directory_path . 'mutation/class-order-delete.php'; require $include_directory_path . 'mutation/class-order-update.php'; + require $include_directory_path . 'mutation/class-payment-method-delete.php'; + require $include_directory_path . 'mutation/class-payment-method-set-default.php'; + require $include_directory_path . 'mutation/class-product-attribute-create.php'; + require $include_directory_path . 'mutation/class-product-attribute-delete.php'; + require $include_directory_path . 'mutation/class-product-attribute-term-create.php'; + require $include_directory_path . 'mutation/class-product-attribute-term-delete.php'; + require $include_directory_path . 'mutation/class-product-attribute-term-update.php'; + require $include_directory_path . 'mutation/class-product-attribute-update.php'; + require $include_directory_path . 'mutation/class-product-create.php'; + require $include_directory_path . 'mutation/class-product-delete.php'; + require $include_directory_path . 'mutation/class-product-update.php'; + require $include_directory_path . 'mutation/class-product-variation-create.php'; + require $include_directory_path . 'mutation/class-product-variation-delete.php'; + require $include_directory_path . 'mutation/class-product-variation-update.php'; require $include_directory_path . 'mutation/class-review-write.php'; require $include_directory_path . 'mutation/class-review-delete-restore.php'; require $include_directory_path . 'mutation/class-review-update.php'; - require $include_directory_path . 'mutation/class-payment-method-delete.php'; - require $include_directory_path . 'mutation/class-payment-method-set-default.php'; require $include_directory_path . 'mutation/class-update-session.php'; // Include connection class/function files. diff --git a/includes/data/mutation/class-product-mutation.php b/includes/data/mutation/class-product-mutation.php new file mode 100644 index 000000000..c68b1dcd0 --- /dev/null +++ b/includes/data/mutation/class-product-mutation.php @@ -0,0 +1,391 @@ +set_weight( '' ); + $product->set_height( '' ); + $product->set_length( '' ); + $product->set_width( '' ); + } else { + if ( isset( $input['weight'] ) ) { + $product->set_weight( $input['weight'] ); + } + + if ( isset( $input['dimensions']['height'] ) ) { + $product->set_height( $input['dimensions']['height'] ); + } + + if ( isset( $input['dimensions']['width'] ) ) { + $product->set_width( $input['dimensions']['width'] ); + } + + if ( isset( $input['dimensions']['length'] ) ) { + $product->set_length( $input['dimensions']['length'] ); + } + } + + if ( isset( $input['shippingClass'] ) ) { + /** + * Shipping class. + * + * @var string $shippingClass + */ + $shippingClass = wc_clean( $input['shippingClass'] ); + /** + * WC product data store instance. + * + * @var \WC_Product_Data_Store_Interface $data_store + */ + $data_store = $product->get_data_store(); + $shipping_class_id = $data_store->get_shipping_class_id_by_slug( $shippingClass ); + if ( $shipping_class_id ) { + $product->set_shipping_class_id( $shipping_class_id ); + } else { + graphql_debug( + /* translators: %s: Shipping class */ + sprintf( __( 'Invalid shipping class: %s', 'wp-graphql-woocommerce' ), $shippingClass ) + ); + } + } + + return $product; + } + + /** + * Prepare product attribute + * + * @param array $attribute Product attribute data. + * + * @return \WC_Product_Attribute|null + */ + public static function prepare_attribute( $attribute ) { + /** + * Attribute ID. + * + * @var int $attribute_id + */ + $attribute_id = 0; + /** + * Attribute name. + * + * @var string $attribute_name + */ + $attribute_name = ''; + + if ( ! empty( $attribute['id'] ) ) { + $attribute_id = absint( $attribute['id'] ); + $attribute_name = wc_attribute_taxonomy_name_by_id( $attribute_id ); + } elseif ( ! empty( $attribute['name'] ) ) { + /** + * @var string $attribute_name + */ + $attribute_name = wc_clean( $attribute['name'] ); + } + + if ( ! $attribute_id && ! $attribute_name ) { + return null; + } + + if ( $attribute_id ) { + if ( isset( $attribute['options'] ) ) { + $options = $attribute['options']; + + if ( ! is_array( $options ) ) { + $options = explode( WC_DELIMITER, $options ); + } + + $values = array_map( 'wc_sanitize_term_text_based', $options ); + $values = array_filter( $values, 'strlen' ); + } else { + $values = []; + } + + if ( ! empty( $values ) ) { + $attribute_object = new \WC_Product_Attribute(); + $attribute_object->set_id( $attribute_id ); + $attribute_object->set_name( $attribute_name ); + $attribute_object->set_options( $values ); + $attribute_object->set_position( isset( $attribute['position'] ) ? absint( $attribute['position'] ) : 0 ); + $attribute_object->set_visible( isset( $attribute['visible'] ) ? $attribute['visible'] : false ); + $attribute_object->set_variation( isset( $attribute['variation'] ) ? $attribute['variation'] : false ); + + return $attribute_object; + } + } elseif ( isset( $attribute['options'] ) ) { + if ( is_array( $attribute['options'] ) ) { + $values = $attribute['options']; + } else { + $values = explode( WC_DELIMITER, $attribute['options'] ); + } + + $attribute_object = new \WC_Product_Attribute(); + $attribute_object->set_name( $attribute_name ); + $attribute_object->set_options( $values ); + $attribute_object->set_position( isset( $attribute['position'] ) ? absint( $attribute['position'] ) : 0 ); + $attribute_object->set_visible( isset( $attribute['visible'] ) ? $attribute['visible'] : false ); + $attribute_object->set_variation( isset( $attribute['variation'] ) ? $attribute['variation'] : false ); + + return $attribute_object; + } + + return null; + } + + /** + * Save product attributes + * + * @param \WC_Product $product Product instance. + * @param array $term_ids Terms IDs. + * @param string $taxonomy Taxonomy name. + * + * @return \WC_Product + */ + public static function save_taxonomy_terms( $product, $term_ids, $taxonomy = 'cat' ) { + if ( 'cat' === $taxonomy ) { + $product->set_category_ids( $term_ids ); + } elseif ( 'tag' === $taxonomy ) { + $product->set_tag_ids( $term_ids ); + } + + return $product; + } + + /** + * Save product downloadable files + * + * @param \WC_Product|\WC_Product_Variation $product Product instance. + * @param array $downloads Downloads data. + * + * @return \WC_Product|\WC_Product_Variation + */ + public static function save_downloadable_files( $product, $downloads ) { + $files = []; + foreach ( $downloads as $key => $file ) { + if ( empty( $file['file'] ) ) { + continue; + } + + $download = new \WC_Product_Download(); + $download->set_id( ! empty( $file['id'] ) ? $file['id'] : wp_generate_uuid4() ); + $download->set_name( $file['name'] ? $file['name'] : wc_get_filename_from_url( $file['file'] ) ); + $download->set_file( apply_filters( 'woocommerce_file_download_path', $file['file'], $product, $key ) ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound + $files[] = $download; + } + + $product->set_downloads( $files ); + + return $product; + } + + /** + * Save variable product default attributes + * + * @param \WC_Product $product Product object. + * @param array $input Mutation input. + * + * @return \WC_Product + */ + public static function save_default_attributes( $product, $input ) { + if ( ! empty( $input['defaultAttributes'] ) ) { + $attributes = $product->get_attributes(); + $default_attributes = []; + + foreach ( $input['defaultAttributes'] as $attribute ) { + /** + * Attribute ID. + * + * @var int $attribute_id + */ + $attribute_id = 0; + /** + * Attribute name. + * + * @var string $attribute_name + */ + $attribute_name = ''; + + if ( ! empty( $attribute['id'] ) ) { + $attribute_id = absint( $attribute['id'] ); + $attribute_name = wc_attribute_taxonomy_name_by_id( $attribute_id ); + } elseif ( ! empty( $attribute['attributeName'] ) ) { + $attribute_name = sanitize_title( $attribute['attributeName'] ); + } + + if ( ! $attribute_id && ! $attribute_name ) { + continue; + } + + if ( isset( $attributes[ $attribute_name ] ) ) { + $_attribute = $attributes[ $attribute_name ]; + + if ( $_attribute['is_variation'] ) { + /** + * Attribute value. + * + * @var string $value + */ + $value = isset( $attribute['attributeValue'] ) ? wc_clean( stripslashes( $attribute['attributeValue'] ) ) : ''; + + if ( ! empty( $_attribute['is_taxonomy'] ) ) { + $term = get_term_by( 'name', $value, $attribute_name ); + + if ( $term && ! is_wp_error( $term ) ) { + $value = $term->slug; + } else { + $value = sanitize_title( $value ); + } + } + + if ( $value ) { + $default_attributes[ $attribute_name ] = $value; + } + } + } + } + + $product->set_default_attributes( $default_attributes ); + } + + return $product; + } + + /** + * Set product images + * + * @param \WC_Product $product Product instance. + * @param array $images Images data. + * + * @throws \GraphQL\Error\UserError If image upload fails | Invalid image ID. + * + * @return \WC_Product + */ + public static function set_product_images( $product, $images ) { + $images = is_array( $images ) ? array_filter( $images ) : []; + + if ( ! empty( $images ) ) { + $gallery_positions = []; + + foreach ( $images as $index => $image ) { + $attachment_id = isset( $image['id'] ) ? absint( $image['id'] ) : 0; + + if ( 0 === $attachment_id && isset( $image['src'] ) ) { + $upload = \wc_rest_upload_image_from_url( $image['src'] ); + + if ( is_wp_error( $upload ) ) { + if ( ! apply_filters( 'woocommerce_rest_suppress_image_upload_error', false, $upload, $product->get_id(), $images ) ) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound + throw new UserError( $upload->get_error_message() ); + } else { + continue; + } + } + + $attachment_id = \wc_rest_set_uploaded_image_as_attachment( $upload, $product->get_id() ); + } + + if ( ! wp_attachment_is_image( $attachment_id ) ) { + throw new UserError( + /* translators: %s: Attachment ID */ + sprintf( __( '#%s is an invalid image ID.', 'wp-graphql-woocommerce' ), $attachment_id ) + ); + } + + $gallery_positions[ $attachment_id ] = absint( isset( $image['position'] ) ? $image['position'] : $index ); + + // Set the image alt if present. + if ( ! empty( $image['altText'] ) ) { + update_post_meta( $attachment_id, '_wp_attachment_image_alt', wc_clean( $image['alt'] ) ); + } + + // Set the image name if present. + if ( ! empty( $image['name'] ) ) { + wp_update_post( + [ + 'ID' => $attachment_id, + 'post_title' => $image['name'], + ] + ); + } + + // Set the image source if present, for future reference. + if ( ! empty( $image['src'] ) ) { + update_post_meta( $attachment_id, '_wc_attachment_source', esc_url_raw( $image['src'] ) ); + } + } + + // Sort images and get IDs in correct order. + asort( $gallery_positions ); + + // Get gallery in correct order. + $gallery = array_keys( $gallery_positions ); + + /** + * Featured image ID in position 0. + * + * @var int $image_id + */ + $image_id = array_shift( $gallery ); + + // Set images. + $product->set_image_id( $image_id ); + $product->set_gallery_image_ids( $gallery ); + } else { + $product->set_image_id( '' ); + $product->set_gallery_image_ids( [] ); + } + + return $product; + } + + /** + * Retrieve product attribute + * + * @param int $id Attribute ID. + * + * @throws \GraphQL\Error\UserError If attribute ID is invalid. + * + * @return object{'attribute_id': int} + */ + public static function get_attribute( $id ) { + global $wpdb; + + $attribute = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $wpdb->prepare( + " + SELECT * + FROM {$wpdb->prefix}woocommerce_attribute_taxonomies + WHERE attribute_id = %d + ", + $id + ) + ); + + if ( is_wp_error( $attribute ) || is_null( $attribute ) ) { + throw new UserError( __( 'Invalid attribute ID.', 'wp-graphql-woocommerce' ) ); + } + + return $attribute; + } +} diff --git a/includes/model/class-product.php b/includes/model/class-product.php index 09ccd4e94..dbedb888e 100644 --- a/includes/model/class-product.php +++ b/includes/model/class-product.php @@ -92,6 +92,7 @@ * * @property int[] $variation_ids * + * @mixin \WC_Product * @package WPGraphQL\WooCommerce\Model */ class Product extends WC_Post { diff --git a/includes/mutation/class-product-attribute-create.php b/includes/mutation/class-product-attribute-create.php new file mode 100644 index 000000000..53becba8e --- /dev/null +++ b/includes/mutation/class-product-attribute-create.php @@ -0,0 +1,125 @@ + self::get_input_fields(), + 'outputFields' => self::get_output_fields(), + 'mutateAndGetPayload' => self::mutate_and_get_payload(), + ] + ); + } + + /** + * Defines the mutation input field configuration + * + * @return array + */ + public static function get_input_fields() { + return [ + 'name' => [ + 'type' => [ 'non_null' => 'String' ], + 'description' => __( 'Name of the attribute.', 'wp-graphql-woocommerce' ), + ], + 'slug' => [ + 'type' => 'String', + 'description' => __( 'Slug of the attribute.', 'wp-graphql-woocommerce' ), + ], + 'type' => [ + 'type' => 'String', + 'description' => __( 'Type of the attribute.', 'wp-graphql-woocommerce' ), + ], + 'orderBy' => [ + 'type' => 'String', + 'description' => __( 'Order by which the attribute should be sorted.', 'wp-graphql-woocommerce' ), + ], + 'hasArchives' => [ + 'type' => 'Boolean', + 'description' => __( 'Whether the attribute has archives.', 'wp-graphql-woocommerce' ), + ], + ]; + } + + /** + * Defines the mutation output field configuration + * + * @return array + */ + public static function get_output_fields() { + return [ + 'attribute' => [ + 'type' => 'ProductAttributeObject', + 'resolve' => static function ( $payload ) { + return $payload['attribute']; + }, + ], + ]; + } + + /** + * Defines the mutation data modification closure. + * + * @return callable + */ + public static function mutate_and_get_payload() { + return static function ( $input, AppContext $context, ResolveInfo $info ) { + if ( ! wc_rest_check_manager_permissions( 'attributes', 'create' ) ) { + throw new UserError( __( 'Sorry, you are not allowed to create attributes.', 'wp-graphql-woocommerce' ) ); + } + + $attribute_id = wc_create_attribute( + [ + 'name' => $input['name'], + 'slug' => \wc_sanitize_taxonomy_name( stripslashes( $input['slug'] ) ), + 'type' => ! empty( $input['type'] ) ? $input['type'] : 'select', + 'order_by' => ! empty( $input['orderBy'] ) ? $input['orderBy'] : 'menu_order', + 'has_archives' => true === $input['hasArchives'], + ] + ); + + // Checks for errors. + if ( is_wp_error( $attribute_id ) ) { + throw new UserError( $attribute_id->get_error_message() ); + } + + $attribute = Product_Mutation::get_attribute( $attribute_id ); + + /** + * Fires after a single product attribute is created or updated via the REST API. + * + * @param object{'attribute_id': int} $attribute Inserted attribute object. + * @param array $input Request object. + * @param boolean $creating True when creating attribute, false when updating. + */ + do_action( 'graphql_woocommerce_insert_product_attribute', $attribute, $input, true ); + + + return [ 'attribute' => $attribute ]; + }; + } +} diff --git a/includes/mutation/class-product-attribute-delete.php b/includes/mutation/class-product-attribute-delete.php new file mode 100644 index 000000000..a1fbcdca2 --- /dev/null +++ b/includes/mutation/class-product-attribute-delete.php @@ -0,0 +1,96 @@ + self::get_input_fields(), + 'outputFields' => self::get_output_fields(), + 'mutateAndGetPayload' => self::mutate_and_get_payload(), + ] + ); + } + + /** + * Defines the mutation input field configuration + * + * @return array + */ + public static function get_input_fields() { + return [ + 'id' => [ + 'type' => [ 'non_null' => 'ID' ], + 'description' => __( 'Unique identifier for the product.', 'wp-graphql-woocommerce' ), + ], + ]; + } + + /** + * Defines the mutation output field configuration + * + * @return array + */ + public static function get_output_fields() { + return [ + 'attribute' => [ + 'type' => 'ProductAttributeObject', + 'resolve' => static function ( $payload ) { + return $payload['attribute']; + }, + ], + ]; + } + + /** + * Defines the mutation data modification closure. + * + * @return callable + */ + public static function mutate_and_get_payload() { + return static function ( $input, AppContext $context, ResolveInfo $info ) { + if ( ! wc_rest_check_manager_permissions( 'attributes', 'delete' ) ) { + throw new UserError( __( 'Sorry, you cannot delete attributes.', 'wp-graphql-woocommerce' ) ); + } + + $attribute = Product_Mutation::get_attribute( $input['id'] ); + $deleted = wc_delete_attribute( $attribute->attribute_id ); + + if ( false === $deleted ) { + throw new UserError( __( 'Failed to delete attribute.', 'wp-graphql-woocommerce' ) ); + } + + /** + * Fires after a single attribute is deleted via the REST API. + * + * @param object{attribute_id: int} $attribute The deleted attribute. + */ + do_action( 'graphql_woocommerce_delete_product_attribute', $attribute ); + + return [ 'attribute' => $attribute ]; + }; + } +} diff --git a/includes/mutation/class-product-attribute-term-create.php b/includes/mutation/class-product-attribute-term-create.php new file mode 100644 index 000000000..82f6e9688 --- /dev/null +++ b/includes/mutation/class-product-attribute-term-create.php @@ -0,0 +1,185 @@ + self::get_input_fields(), + 'outputFields' => self::get_output_fields(), + 'mutateAndGetPayload' => [ self::class, 'mutate_and_get_payload' ], + ] + ); + } + + /** + * Defines the mutation input field configuration + * + * @return array + */ + public static function get_input_fields() { + return [ + 'attributeId' => [ + 'type' => [ 'non_null' => 'Int' ], + 'description' => __( 'The ID of the attribute to which the term belongs.', 'wp-graphql-woocommerce' ), + ], + 'name' => [ + 'type' => [ 'non_null' => 'String' ], + 'description' => __( 'The name of the term.', 'wp-graphql-woocommerce' ), + ], + 'slug' => [ + 'type' => 'String', + 'description' => __( 'The slug of the term.', 'wp-graphql-woocommerce' ), + ], + 'description' => [ + 'type' => 'String', + 'description' => __( 'The description of the term.', 'wp-graphql-woocommerce' ), + ], + 'menuOrder' => [ + 'type' => 'Int', + 'description' => __( 'The order of the term in the menu.', 'wp-graphql-woocommerce' ), + ], + ]; + } + + /** + * Defines the mutation output field configuration + * + * @return array + */ + public static function get_output_fields() { + return [ + 'term' => [ + 'type' => 'ProductAttributeTermObject', + 'resolve' => static function ( $payload ) { + return (object) $payload['term']; + }, + ], + ]; + } + + /** + * Defines the mutation data modification closure. + * + * @param array $input Mutation input. + * @param \WPGraphQL\AppContext $context AppContext instance. + * @param \GraphQL\Type\Definition\ResolveInfo $info ResolveInfo instance. Can be + * use to get info about the current node in the GraphQL tree. + * + * @throws \GraphQL\Error\UserError Invalid ID provided | Lack of capabilities. + * + * @return array + */ + public static function mutate_and_get_payload( $input, AppContext $context, ResolveInfo $info ) { + if ( ! $input['attributeId'] ) { + throw new UserError( __( 'An attributeId is required to create a new product attribute term.', 'wp-graphql-woocommerce' ) ); + } + + $context = 'createProductAttributeTerm' === $info->fieldName ? 'create' : 'edit'; + $taxonomy = wc_attribute_taxonomy_name_by_id( $input['attributeId'] ); + if ( empty( $taxonomy ) ) { + throw new UserError( __( 'Invalid attributeId.', 'wp-graphql-woocommerce' ) ); + } + + if ( ! wc_rest_check_product_term_permissions( $taxonomy, $context ) ) { + throw new UserError( __( 'Sorry, you are not allowed to create product attribute terms.', 'wp-graphql-woocommerce' ) ); + } + + $id = isset( $input['id'] ) ? $input['id'] : null; + $args = []; + + if ( ! empty( $input['description'] ) ) { + $args['description'] = $input['description']; + } + + if ( ! empty( $input['slug'] ) ) { + $args['slug'] = $input['slug']; + } + + if ( $id && ! empty( $input['name'] ) ) { + $args['name'] = $input['name']; + } + + $term = null; + if ( $id ) { + $term = get_term( $id, $taxonomy ); + } + + if ( is_wp_error( $term ) ) { + throw new UserError( $term->get_error_message() ); + } elseif ( $term && ! wc_rest_check_product_term_permissions( $taxonomy, $context, $term->term_id ) ) { + throw new UserError( __( 'Sorry, you are not allowed to update this product attribute term.', 'wp-graphql-woocommerce' ) ); + } + + if ( $id ) { + $term = wp_update_term( $id, $taxonomy, $args ); + } elseif ( ! empty( $input['name'] ) ) { + $name = $input['name']; + $term = wp_insert_term( $name, $taxonomy, $args ); + } else { + $updating = 'updateProductAttributeTerm' === $info->fieldName; + throw new UserError( + $updating + ? __( 'A name is required to create a new product attribute term.', 'wp-graphql-woocommerce' ) + : __( 'A valid term "id" and changeable parameter are required to update a product attribute term.', 'wp-graphql-woocommerce' ) + ); + } + + if ( is_wp_error( $term ) ) { + throw new UserError( $term->get_error_message() ); + } + + /** + * Newly created product attribute term. + * + * @var \WP_Term|\WP_Error|null $term + */ + $term = get_term( $term['term_id'], $taxonomy ); + if ( ! $term ) { + throw new UserError( __( 'Failed to retrieve term for modification. Please check input.', 'wp-graphql-woocommerce' ) ); + } elseif ( is_wp_error( $term ) ) { + throw new UserError( $term->get_error_message() ); + } + + if ( isset( $input['menuOrder'] ) ) { + $success = update_term_meta( $term->term_id, 'order_' . $taxonomy, $input['menuOrder'] ); + if ( is_wp_error( $success ) ) { + throw new UserError( $success->get_error_message() ); + } + } + + $menu_order = get_term_meta( $term->term_id, 'order_' . $taxonomy, true ); + $data = [ + 'id' => $term->term_id, + 'name' => $term->name, + 'slug' => $term->slug, + 'description' => $term->description, + 'menu_order' => (int) $menu_order, + 'count' => (int) $term->count, + ]; + + return [ 'term' => $data ]; + } +} diff --git a/includes/mutation/class-product-attribute-term-delete.php b/includes/mutation/class-product-attribute-term-delete.php new file mode 100644 index 000000000..b85efc14e --- /dev/null +++ b/includes/mutation/class-product-attribute-term-delete.php @@ -0,0 +1,129 @@ + self::get_input_fields(), + 'outputFields' => self::get_output_fields(), + 'mutateAndGetPayload' => self::mutate_and_get_payload(), + ] + ); + } + + /** + * Defines the mutation input field configuration + * + * @return array + */ + public static function get_input_fields() { + return [ + 'attributeId' => [ + 'type' => [ 'non_null' => 'Int' ], + 'description' => __( 'The ID of the attribute to which the term belongs.', 'wp-graphql-woocommerce' ), + ], + 'id' => [ + 'type' => [ 'non_null' => 'Int' ], + 'description' => __( 'The ID of the term to update.', 'wp-graphql-woocommerce' ), + ], + ]; + } + + /** + * Defines the mutation output field configuration + * + * @return array + */ + public static function get_output_fields() { + return [ + 'term' => [ + 'type' => 'ProductAttributeTermObject', + 'resolve' => static function ( $payload ) { + return (object) $payload['term']; + }, + ], + ]; + } + + /** + * Defines the mutation data modification closure. + * + * @return callable + */ + public static function mutate_and_get_payload() { + return static function ( $input, AppContext $context, ResolveInfo $info ) { + if ( ! $input['attributeId'] ) { + throw new UserError( __( 'A valid attributeId is required to create a new product attribute term.', 'wp-graphql-woocommerce' ) ); + } + + $taxonomy = wc_attribute_taxonomy_name_by_id( $input['attributeId'] ); + if ( empty( $taxonomy ) ) { + throw new UserError( __( 'Invalid attribute ID.', 'wp-graphql-woocommerce' ) ); + } + + if ( ! $input['id'] ) { + throw new UserError( __( 'A valid term ID is required to delete a product attribute term.', 'wp-graphql-woocommerce' ) ); + } + + $term = get_term( $input['id'], $taxonomy ); + if ( ! $term ) { + throw new UserError( __( 'Invalid term ID.', 'wp-graphql-woocommerce' ) ); + } elseif ( is_wp_error( $term ) ) { + throw new UserError( $term->get_error_message() ); + } + + if ( ! wc_rest_check_product_term_permissions( $taxonomy, 'delete', $term->term_id ) ) { + throw new UserError( __( 'You do not have permission to delete this term.', 'wp-graphql-woocommerce' ) ); + } + + $menu_order = get_term_meta( $term->term_id, 'order_' . $taxonomy, true ); + + $data = [ + 'id' => $term->term_id, + 'name' => $term->name, + 'slug' => $term->slug, + 'description' => $term->description, + 'menu_order' => ! empty( $menu_order ) ? absint( $menu_order ) : 0, + 'count' => absint( $term->count ), + ]; + + $retval = wp_delete_term( $term->term_id, $term->taxonomy ); + if ( ! $retval ) { + throw new UserError( __( 'Failed to delete term.', 'wp-graphql-woocommerce' ) ); + } + + /** + * Fires after a single term is deleted via the REST API. + * + * @param \WP_Term $term The deleted term. + * @param array $input Mutation input. + */ + do_action( "graphql_woocommerce_delete_{$taxonomy}", $term, $input ); + + return [ 'term' => $data ]; + }; + } +} diff --git a/includes/mutation/class-product-attribute-term-update.php b/includes/mutation/class-product-attribute-term-update.php new file mode 100644 index 000000000..270a27859 --- /dev/null +++ b/includes/mutation/class-product-attribute-term-update.php @@ -0,0 +1,69 @@ + self::get_input_fields(), + 'outputFields' => self::get_output_fields(), + 'mutateAndGetPayload' => [ Product_Attribute_Term_Create::class, 'mutate_and_get_payload' ], + ] + ); + } + + /** + * Defines the mutation input field configuration + * + * @return array + */ + public static function get_input_fields() { + return array_merge( + Product_Attribute_Term_Create::get_input_fields(), + [ + 'id' => [ + 'type' => [ 'non_null' => 'Int' ], + 'description' => __( 'The ID of the term to update.', 'wp-graphql-woocommerce' ), + ], + 'name' => [ + 'type' => 'String', + 'description' => __( 'The name of the term.', 'wp-graphql-woocommerce' ), + ], + ] + ); + } + + /** + * Defines the mutation output field configuration + * + * @return array + */ + public static function get_output_fields() { + return [ + 'term' => [ + 'type' => 'ProductAttributeTermObject', + 'resolve' => static function ( $payload ) { + return (object) $payload['term']; + }, + ], + ]; + } +} diff --git a/includes/mutation/class-product-attribute-update.php b/includes/mutation/class-product-attribute-update.php new file mode 100644 index 000000000..d32f7f75b --- /dev/null +++ b/includes/mutation/class-product-attribute-update.php @@ -0,0 +1,115 @@ + self::get_input_fields(), + 'outputFields' => self::get_output_fields(), + 'mutateAndGetPayload' => self::mutate_and_get_payload(), + ] + ); + } + + /** + * Defines the mutation input field configuration + * + * @return array + */ + public static function get_input_fields() { + return array_merge( + [ + 'id' => [ + 'type' => [ 'non_null' => 'ID' ], + 'description' => __( 'Unique identifier for the product.', 'wp-graphql-woocommerce' ), + ], + ], + Product_Attribute_Create::get_input_fields() + ); + } + + /** + * Defines the mutation output field configuration + * + * @return array + */ + public static function get_output_fields() { + return [ + 'attribute' => [ + 'type' => 'ProductAttributeObject', + 'resolve' => static function ( $payload ) { + return $payload['attribute']; + }, + ], + ]; + } + + /** + * Defines the mutation data modification closure. + * + * @return callable + */ + public static function mutate_and_get_payload() { + return static function ( $input, AppContext $context, ResolveInfo $info ) { + global $wpdb; + + if ( ! wc_rest_check_manager_permissions( 'attributes', 'edit' ) ) { + throw new UserError( __( 'Sorry, you are not allowed to edit attributes.', 'wp-graphql-woocommerce' ) ); + } + + $id = (int) $input['id']; + $edited = \wc_update_attribute( + $id, + [ + 'name' => $input['name'], + 'slug' => \wc_sanitize_taxonomy_name( stripslashes( $input['slug'] ) ), + 'type' => ! empty( $input['type'] ) ? $input['type'] : 'select', + 'order_by' => ! empty( $input['orderBy'] ) ? $input['orderBy'] : 'menu_order', + 'has_archives' => true === $input['hasArchives'], + ] + ); + + // Checks for errors. + if ( is_wp_error( $edited ) ) { + throw new UserError( $edited->get_error_message() ); + } + + $attribute = Product_Mutation::get_attribute( $id ); + + /** + * Fires after a single product attribute is created or updated via the REST API. + * + * @param object{'attribute_id': int} $attribute Inserted attribute object. + * @param array $input Request object. + * @param boolean $creating True when creating attribute, false when updating. + */ + do_action( 'graphql_woocommerce_insert_product_attribute', $attribute, $input, false ); + + return [ 'attribute' => $attribute ]; + }; + } +} diff --git a/includes/mutation/class-product-create.php b/includes/mutation/class-product-create.php new file mode 100644 index 000000000..9df3ea59a --- /dev/null +++ b/includes/mutation/class-product-create.php @@ -0,0 +1,534 @@ + self::get_input_fields(), + 'outputFields' => self::get_output_fields(), + 'mutateAndGetPayload' => [ self::class, 'mutate_and_get_payload' ], + ] + ); + } + + /** + * Defines the mutation input field configuration + * + * @return array + */ + public static function get_input_fields() { + return [ + 'name' => [ + 'type' => [ 'non_null' => 'String' ], + 'description' => __( 'Name of the product.', 'wp-graphql-woocommerce' ), + ], + 'slug' => [ + 'type' => 'String', + 'description' => __( 'Product slug.', 'wp-graphql-woocommerce' ), + ], + 'type' => [ + 'type' => 'ProductTypesEnum', + 'description' => __( 'Type of the product.', 'wp-graphql-woocommerce' ), + ], + 'status' => [ + 'type' => 'PostStatusEnum', + 'description' => __( 'Status of the product.', 'wp-graphql-woocommerce' ), + ], + 'featured' => [ + 'type' => 'Boolean', + 'description' => __( 'Featured product.', 'wp-graphql-woocommerce' ), + ], + 'catalogVisibility' => [ + 'type' => 'CatalogVisibilityEnum', + 'description' => __( 'Catalog visibility.', 'wp-graphql-woocommerce' ), + ], + 'description' => [ + 'type' => 'String', + 'description' => __( 'Product description.', 'wp-graphql-woocommerce' ), + ], + 'shortDescription' => [ + 'type' => 'String', + 'description' => __( 'Product short description.', 'wp-graphql-woocommerce' ), + ], + 'sku' => [ + 'type' => 'String', + 'description' => __( 'Product SKU.', 'wp-graphql-woocommerce' ), + ], + 'regularPrice' => [ + 'type' => 'Float', + 'description' => __( 'Product regular price.', 'wp-graphql-woocommerce' ), + ], + 'salePrice' => [ + 'type' => 'Float', + 'description' => __( 'Product sale price.', 'wp-graphql-woocommerce' ), + ], + 'dateOnSaleFrom' => [ + 'type' => 'String', + 'description' => __( 'Product sale start date.', 'wp-graphql-woocommerce' ), + ], + 'dateOnSaleTo' => [ + 'type' => 'String', + 'description' => __( 'Product sale end date.', 'wp-graphql-woocommerce' ), + ], + 'virtual' => [ + 'type' => 'Boolean', + 'description' => __( 'Product virtual.', 'wp-graphql-woocommerce' ), + ], + 'downloadable' => [ + 'type' => 'Boolean', + 'description' => __( 'Product downloadable.', 'wp-graphql-woocommerce' ), + ], + 'downloads' => [ + 'type' => [ 'list_of' => 'ProductDownloadInput' ], + 'description' => __( 'Product downloads.', 'wp-graphql-woocommerce' ), + ], + 'downloadLimit' => [ + 'type' => 'Int', + 'description' => __( 'Product download limit.', 'wp-graphql-woocommerce' ), + ], + 'downloadExpiry' => [ + 'type' => 'Int', + 'description' => __( 'Number of days until download access expires.', 'wp-graphql-woocommerce' ), + ], + 'externalUrl' => [ + 'type' => 'String', + 'description' => __( 'Product external URL. (External products only)', 'wp-graphql-woocommerce' ), + ], + 'buttonText' => [ + 'type' => 'String', + 'description' => __( 'Product button text. (External products only)', 'wp-graphql-woocommerce' ), + ], + 'taxStatus' => [ + 'type' => 'TaxStatusEnum', + 'description' => __( 'Tax status.', 'wp-graphql-woocommerce' ), + ], + 'taxClass' => [ + 'type' => 'TaxClassEnum', + 'description' => __( 'Tax class.', 'wp-graphql-woocommerce' ), + ], + 'manageStock' => [ + 'type' => 'Boolean', + 'description' => __( 'Manage stock.', 'wp-graphql-woocommerce' ), + ], + 'stockQuantity' => [ + 'type' => 'Int', + 'description' => __( 'Stock quantity.', 'wp-graphql-woocommerce' ), + ], + 'stockStatus' => [ + 'type' => 'StockStatusEnum', + 'description' => __( 'Stock status.', 'wp-graphql-woocommerce' ), + ], + 'backorders' => [ + 'type' => 'BackordersEnum', + 'description' => __( 'Backorders.', 'wp-graphql-woocommerce' ), + ], + 'soldIndividually' => [ + 'type' => 'Boolean', + 'description' => __( 'Sold individually.', 'wp-graphql-woocommerce' ), + ], + 'weight' => [ + 'type' => 'String', + 'description' => __( 'Product weight.', 'wp-graphql-woocommerce' ), + ], + 'dimensions' => [ + 'type' => 'ProductDimensionsInput', + 'description' => __( 'Product dimensions.', 'wp-graphql-woocommerce' ), + ], + 'shippingClass' => [ + 'type' => 'String', + 'description' => __( 'Shipping class.', 'wp-graphql-woocommerce' ), + ], + 'reviewsAllowed' => [ + 'type' => 'Boolean', + 'description' => __( 'Allow reviews. Default is true', 'wp-graphql-woocommerce' ), + ], + 'upsellIds' => [ + 'type' => [ 'list_of' => 'Int' ], + 'description' => __( 'Upsell product IDs.', 'wp-graphql-woocommerce' ), + ], + 'crossSellIds' => [ + 'type' => [ 'list_of' => 'Int' ], + 'description' => __( 'Cross-sell product IDs.', 'wp-graphql-woocommerce' ), + ], + 'parentId' => [ + 'type' => 'Int', + 'description' => __( 'Parent product ID.', 'wp-graphql-woocommerce' ), + ], + 'purchaseNote' => [ + 'type' => 'String', + 'description' => __( 'Purchase note.', 'wp-graphql-woocommerce' ), + ], + 'categories' => [ + 'type' => [ 'list_of' => 'Int' ], + 'description' => __( 'Product categories.', 'wp-graphql-woocommerce' ), + ], + 'tags' => [ + 'type' => [ 'list_of' => 'Int' ], + 'description' => __( 'Product tags.', 'wp-graphql-woocommerce' ), + ], + 'images' => [ + 'type' => [ 'list_of' => 'ProductImageInput' ], + 'description' => __( 'Product images.', 'wp-graphql-woocommerce' ), + ], + 'attributes' => [ + 'type' => [ 'list_of' => 'ProductAttributesInput' ], + 'description' => __( 'Product attributes.', 'wp-graphql-woocommerce' ), + ], + 'defaultAttributes' => [ + 'type' => [ 'list_of' => 'ProductAttributeInput' ], + 'description' => __( 'Product default attributes.', 'wp-graphql-woocommerce' ), + ], + 'groupedProducts' => [ + 'type' => [ 'list_of' => 'Int' ], + 'description' => __( 'Grouped product IDs.', 'wp-graphql-woocommerce' ), + ], + 'menuOrder' => [ + 'type' => 'Int', + 'description' => __( 'Menu order.', 'wp-graphql-woocommerce' ), + ], + 'metaData' => [ + 'type' => [ 'list_of' => 'MetaDataInput' ], + 'description' => __( 'Meta data.', 'wp-graphql-woocommerce' ), + ], + ]; + } + + /** + * Defines the mutation output field configuration + * + * @return array + */ + public static function get_output_fields() { + return [ + 'product' => [ + 'type' => 'Product', + 'resolve' => static function ( $payload ) { + return new Product( $payload['id'] ); + }, + ], + 'productId' => [ + 'type' => 'Int', + 'resolve' => static function ( $payload ) { + return $payload['id']; + }, + ], + ]; + } + + /** + * Defines the mutation data modification closure. + * + * @param array $input Mutation input. + * @param \WPGraphQL\AppContext $context AppContext instance. + * @param \GraphQL\Type\Definition\ResolveInfo $info ResolveInfo instance. Can be + * use to get info about the current node in the GraphQL tree. + * + * @throws \GraphQL\Error\UserError Invalid ID provided | Lack of capabilities. + * + * @return array + */ + public static function mutate_and_get_payload( $input, AppContext $context, ResolveInfo $info ) { + $product_id = ! empty( $input['id'] ) ? $input['id'] : 0; + $type = ! empty( $input['type'] ) ? $input['type'] : 'simple'; + + if ( 0 !== $product_id ) { + /** + * @var \WC_Product $product + */ + $product = \wc_get_product( $product_id ); + if ( $product && ! wc_rest_check_post_permissions( 'product', 'edit', $product->get_id() ) ) { + throw new UserError( __( 'You do not have permission to edit this product', 'wp-graphql-woocommerce' ) ); + } + } else { + $classname = \WC_Product_Factory::get_classname_from_product_type( $type ); + if ( ! $classname || ! class_exists( $classname ) ) { + $classname = '\WC_Product_Simple'; + } + + /** + * @var \WC_Product $product + */ + $product = new $classname( $product_id ); + + /** + * @var \WP_Post_Type $post_type_object + */ + $post_type_object = get_post_type_object( 'product' ); + if ( ! current_user_can( $post_type_object->cap->edit_posts ) ) { + throw new UserError( __( 'You do not have permission to create products', 'wp-graphql-woocommerce' ) ); + } + } + + if ( ! empty( $input['name'] ) ) { + $product->set_name( wp_filter_post_kses( $input['name'] ) ); + } + + if ( ! empty( $input['description'] ) ) { + $product->set_description( wp_filter_post_kses( $input['description'] ) ); + } + + if ( ! empty( $input['shortDescription'] ) ) { + $product->set_short_description( wp_filter_post_kses( $input['shortDescription'] ) ); + } + + if ( ! empty( $input['status'] ) ) { + $product->set_status( get_post_status_object( $input['status'] ) ? $input['status'] : 'draft' ); + } + + if ( ! empty( $input['slug'] ) ) { + $product->set_slug( $input['slug'] ); + } + + if ( ! empty( $input['menuOrder'] ) ) { + $product->set_menu_order( $input['menuOrder'] ); + } + + if ( isset( $input['reviewsAllowed'] ) ) { + $product->set_reviews_allowed( $input['reviewsAllowed'] ); + } + + if ( isset( $input['virtual'] ) ) { + $product->set_virtual( $input['virtual'] ); + } + + if ( ! empty( $input['taxStatus'] ) ) { + $product->set_tax_status( $input['taxStatus'] ); + } + + if ( ! empty( $input['taxClass'] ) ) { + $product->set_tax_class( $input['taxClass'] ); + } + + if ( ! empty( $input['catalogVisibility'] ) ) { + $product->set_catalog_visibility( $input['catalogVisibility'] ); + } + + if ( ! empty( $input['purchaseNote'] ) ) { + $product->set_purchase_note( wp_filter_post_kses( $input['purchaseNote'] ) ); + } + + if ( isset( $input['featured'] ) ) { + $product->set_featured( $input['featured'] ); + } + + $product = Product_Mutation::save_product_shipping_data( $product, $input ); + + if ( ! empty( $input['sku'] ) ) { + /** + * @var string $sku + */ + $sku = wc_clean( $input['sku'] ); + $product->set_sku( $sku ); + } + + if ( ! empty( $input['attributes'] ) ) { + $attributes = []; + + foreach ( $input['attributes'] as $attribute ) { + $attribute_object = Product_Mutation::prepare_attribute( $attribute ); + if ( $attribute_object ) { + $attributes[] = $attribute_object; + } + } + + $product->set_attributes( $attributes ); + } + + if ( in_array( $type, [ 'variable', 'grouped' ], true ) ) { + $product->set_regular_price( '' ); + $product->set_sale_price( '' ); + $product->set_date_on_sale_to( '' ); + $product->set_date_on_sale_from( '' ); + $product->set_price( '' ); + } else { + if ( ! empty( $input['regularPrice'] ) ) { + $product->set_regular_price( $input['regularPrice'] ); + } + + if ( ! empty( $input['salePrice'] ) ) { + $product->set_sale_price( $input['salePrice'] ); + } + + if ( ! empty( $input['dateOnSaleFrom'] ) ) { + $product->set_date_on_sale_from( $input['dateOnSaleFrom'] ); + } + + if ( ! empty( $input['dateOnSaleTo'] ) ) { + $product->set_date_on_sale_to( $input['dateOnSaleTo'] ); + } + } + + if ( ! empty( $input['parentId'] ) ) { + $product->set_parent_id( $input['parentId'] ); + } + + if ( isset( $input['soldIndividually'] ) ) { + $product->set_sold_individually( $input['soldIndividually'] ); + } + + if ( isset( $input['stockStatus'] ) ) { + /** + * @var string $stock_status + */ + $stock_status = wc_clean( $input['stockStatus'] ); + } else { + $stock_status = $product->get_stock_status(); + } + + if ( 'yes' === get_option( 'woocommerce_manage_stock' ) ) { + if ( isset( $input['manageStock'] ) ) { + /** + * @var bool $manage_stock + */ + $manage_stock = $input['manageStock']; + $product->set_manage_stock( $manage_stock ); + } + + + if ( isset( $input['backorders'] ) ) { + $product->set_backorders( $input['backorders'] ); + } + + if ( $product->is_type( 'grouped' ) ) { + $product->set_manage_stock( false ); + $product->set_backorders( 'no' ); + $product->set_stock_quantity( null ); + $product->set_stock_status( $stock_status ); + } elseif ( $product->is_type( 'external' ) ) { + $product->set_manage_stock( false ); + $product->set_backorders( 'no' ); + $product->set_stock_quantity( null ); + $product->set_stock_status( 'instock' ); + } elseif ( $product->get_manage_stock() ) { + // Stock status is always determined by children so sync later. + if ( ! $product->is_type( 'variable' ) ) { + $product->set_stock_status( $stock_status ); + } + + // Stock quantity. + if ( isset( $input['stockQuantity'] ) ) { + $product->set_stock_quantity( wc_stock_amount( $input['stockQuantity'] ) ); + } + } else { + // Don't manage stock. + $product->set_manage_stock( false ); + $product->set_stock_quantity( null ); + $product->set_stock_status( $stock_status ); + } + } elseif ( $product->is_type( 'variable' ) ) { + $product->set_stock_status( $stock_status ); + } + + if ( ! empty( $input['upsellIds'] ) ) { + $product->set_upsell_ids( $input['upsellIds'] ); + } + + if ( ! empty( $input['crossSellIds'] ) ) { + $product->set_cross_sell_ids( $input['crossSellIds'] ); + } + + if ( ! empty( $input['categories'] ) ) { + $product = Product_Mutation::save_taxonomy_terms( $product, $input['categories'] ); + } + + if ( ! empty( $input['tags'] ) ) { + $product = Product_Mutation::save_taxonomy_terms( $product, $input['tags'], 'tag' ); + } + + if ( isset( $input['downloadable'] ) ) { + $product->set_downloadable( $input['downloadable'] ); + } + + if ( $product->get_downloadable() ) { + if ( ! empty( $input['downloads'] ) ) { + $product = Product_Mutation::save_downloadable_files( $product, $input['downloads'] ); + } + + if ( isset( $input['downloadLimit'] ) ) { + $product->set_download_limit( $input['downloadLimit'] ); + } + + if ( isset( $input['downloadExpiry'] ) ) { + $product->set_download_expiry( $input['downloadExpiry'] ); + } + } + + if ( $product->is_type( 'external' ) ) { + if ( ! empty( $input['externalUrl'] ) ) { + /** + * @var \WC_Product_External $product + */ + $product->set_product_url( $input['externalUrl'] ); + } + + if ( ! empty( $input['buttonText'] ) ) { + /** + * @var \WC_Product_External $product + */ + $product->set_button_text( $input['buttonText'] ); + } + } + + if ( $product->is_type( 'variable' ) ) { + $product = Product_Mutation::save_default_attributes( $product, $input ); + } + + if ( $product->is_type( 'grouped' ) && isset( $input['groupedProducts'] ) ) { + /** + * @var \WC_Product_Grouped $product + */ + $product->set_children( $input['groupedProducts'] ); + } + + if ( ! empty( $input['images'] ) ) { + $product = Product_Mutation::set_product_images( $product, $input['images'] ); + } + + if ( ! empty( $input['metaData'] ) ) { + foreach ( $input['metaData'] as $meta ) { + $product->update_meta_data( $meta['key'], $meta['value'], isset( $meta['id'] ) ? $meta['id'] : '' ); + } + } + + /** + * Filters an object before it is inserted via the GraphQL API. + * + * The dynamic portion of the hook name, `$this->post_type`, + * refers to the object type slug. + * + * @param \WC_Product $product Object object. + * @param array $input GraphQL input object. + * @param bool $creating If is creating a new object. + */ + $product = apply_filters( 'graphql_woocommerce_pre_insert_product_object', $product, $input, true ); + + $product_id = $product->save(); + + return [ 'id' => $product_id ]; + } +} diff --git a/includes/mutation/class-product-delete.php b/includes/mutation/class-product-delete.php new file mode 100644 index 000000000..70a8fe11e --- /dev/null +++ b/includes/mutation/class-product-delete.php @@ -0,0 +1,176 @@ + self::get_input_fields(), + 'outputFields' => self::get_output_fields(), + 'mutateAndGetPayload' => self::mutate_and_get_payload(), + ] + ); + } + + /** + * Defines the mutation input field configuration + * + * @return array + */ + public static function get_input_fields() { + return [ + 'id' => [ + 'type' => [ 'non_null' => 'ID' ], + 'description' => __( 'Unique identifier for the product.', 'wp-graphql-woocommerce' ), + ], + 'force' => [ + 'type' => 'Boolean', + 'description' => __( 'Whether to bypass trash and force deletion.', 'wp-graphql-woocommerce' ), + ], + ]; + } + + /** + * Defines the mutation output field configuration + * + * @return array + */ + public static function get_output_fields() { + return [ + 'product' => [ + 'type' => 'Product', + 'resolve' => static function ( $payload ) { + return $payload['product']; + }, + ], + ]; + } + + /** + * Defines the mutation data modification closure. + * + * @return callable + */ + public static function mutate_and_get_payload() { + return static function ( $input, AppContext $context, ResolveInfo $info ) { + $product_id = $input['id']; + $force = isset( $input['force'] ) ? $input['force'] : false; + $object = new Product( $product_id ); + $result = false; + + if ( 0 === $object->ID ) { + throw new UserError( __( 'Invalid product ID.', 'wp-graphql-woocommerce' ) ); + } + + if ( 'variation' === $object->get_type() ) { + throw new UserError( __( 'Variations cannot be deleted with this mutation. Use "deleteProductVariations" instead.', 'wp-graphql-woocommerce' ) ); + } + + $supports_trash = EMPTY_TRASH_DAYS > 0 && is_callable( [ $object, 'get_status' ] ); + + /** + * Filter whether an object is trashable. + * + * Return false to disable trash support for the object. + * + * @param boolean $supports_trash Whether the object type support trashing. + * @param \WPGraphQL\WooCommerce\Model\Product $object The object being considered for trashing support. + */ + $supports_trash = apply_filters( 'graphql_woocommerce_product_object_trashable', $supports_trash, $object ); + + if ( ! wc_rest_check_post_permissions( 'product', 'delete', $object->ID ) ) { + throw new UserError( __( 'Sorry, you are not allowed to delete products', 'wp-graphql-woocommerce' ) ); + } + + /** + * Get the product to be deleted. + * + * @var \WC_Product $product_to_be_deleted + */ + $product_to_be_deleted = \wc_get_product( $object->ID ); + + if ( $force ) { + if ( $product_to_be_deleted->is_type( 'variable' ) ) { + foreach ( $product_to_be_deleted->get_children() as $child_id ) { + $child = wc_get_product( $child_id ); + if ( ! empty( $child ) ) { + $child->delete( true ); + } + } + } else { + // For other product types, if the product has children, remove the relationship. + foreach ( $product_to_be_deleted->get_children() as $child_id ) { + $child = wc_get_product( $child_id ); + if ( ! empty( $child ) ) { + $child->set_parent_id( 0 ); + $child->save(); + } + } + } + + $product_to_be_deleted->delete( true ); + $result = 0 === $product_to_be_deleted->get_id(); + } else { + // If we don't support trashing for this type, error out. + if ( ! $supports_trash ) { + throw new UserError( __( 'This product does not support trashing.', 'wp-graphql-woocommerce' ) ); + } + + if ( is_callable( [ $product_to_be_deleted, 'get_status' ] ) ) { + if ( 'trash' === $product_to_be_deleted->get_status() ) { + throw new UserError( __( 'Product is already in the trash.', 'wp-graphql-woocommerce' ) ); + } + + $product_to_be_deleted->delete(); + + /** + * @var string $status + */ + $status = $product_to_be_deleted->get_status(); + $result = 'trash' === $status; + } + } + + if ( ! $result ) { + throw new UserError( __( 'Failed to delete product.', 'wp-graphql-woocommerce' ) ); + } + + if ( 0 !== $product_to_be_deleted->get_parent_id() ) { + \wc_delete_product_transients( $product_to_be_deleted->get_parent_id() ); + } + + /** + * Fires after a single object is deleted or trashed via the REST API. + * + * @param \WPGraphQL\WooCommerce\Model\Product $object The deleted or trashed object. + * @param array $input The mutation input. + */ + do_action( 'graphql_woocommerce_delete_product_object', $object, $input ); + + return [ 'product' => $object ]; + }; + } +} diff --git a/includes/mutation/class-product-update.php b/includes/mutation/class-product-update.php new file mode 100644 index 000000000..f36ddcfed --- /dev/null +++ b/includes/mutation/class-product-update.php @@ -0,0 +1,67 @@ + self::get_input_fields(), + 'outputFields' => self::get_output_fields(), + 'mutateAndGetPayload' => [ Product_Create::class, 'mutate_and_get_payload' ], + ] + ); + } + + /** + * Defines the mutation input field configuration + * + * @return array + */ + public static function get_input_fields() { + return array_merge( + [ + 'id' => [ + 'type' => [ 'non_null' => 'ID' ], + 'description' => __( 'Unique identifier for the product.', 'wp-graphql-woocommerce' ), + ], + ], + Product_Create::get_input_fields() + ); + } + + /** + * Defines the mutation output field configuration + * + * @return array + */ + public static function get_output_fields() { + return [ + 'product' => [ + 'type' => 'Product', + 'resolve' => static function ( $payload ) { + return new Product( $payload['id'] ); + }, + ], + ]; + } +} diff --git a/includes/mutation/class-product-variation-create.php b/includes/mutation/class-product-variation-create.php new file mode 100644 index 000000000..20985d452 --- /dev/null +++ b/includes/mutation/class-product-variation-create.php @@ -0,0 +1,367 @@ + self::get_input_fields(), + 'outputFields' => self::get_output_fields(), + 'mutateAndGetPayload' => [ self::class, 'mutate_and_get_payload' ], + ] + ); + } + + /** + * Defines the mutation input field configuration + * + * @return array + */ + public static function get_input_fields() { + return [ + 'productId' => [ + 'type' => [ 'non_null' => 'ID' ], + 'description' => __( 'Unique identifier for the product.', 'wp-graphql-woocommerce' ), + ], + 'description' => [ + 'type' => 'String', + 'description' => __( 'Description of the product variation.', 'wp-graphql-woocommerce' ), + ], + 'sku' => [ + 'type' => 'String', + 'description' => __( 'Unique identifier.', 'wp-graphql-woocommerce' ), + ], + 'regularPrice' => [ + 'type' => 'Float', + 'description' => __( 'Regular price of the product variation.', 'wp-graphql-woocommerce' ), + ], + 'salePrice' => [ + 'type' => 'Float', + 'description' => __( 'Sale price of the product variation.', 'wp-graphql-woocommerce' ), + ], + 'dateOnSaleFrom' => [ + 'type' => 'String', + 'description' => __( 'Start date of sale price.', 'wp-graphql-woocommerce' ), + ], + 'dateOnSaleTo' => [ + 'type' => 'String', + 'description' => __( 'End date of sale price.', 'wp-graphql-woocommerce' ), + ], + 'visible' => [ + 'type' => 'boolean', + 'description' => __( 'Is product variation public?', 'wp-graphql-woocommerce' ), + ], + 'virtual' => [ + 'type' => 'Boolean', + 'description' => __( 'Whether the product variation is virtual.', 'wp-graphql-woocommerce' ), + ], + 'downloadable' => [ + 'type' => 'Boolean', + 'description' => __( 'Whether the product variation is downloadable.', 'wp-graphql-woocommerce' ), + ], + 'downloads' => [ + 'type' => [ 'list_of' => 'ProductDownloadInput' ], + 'description' => __( 'Downloadable files.', 'wp-graphql-woocommerce' ), + ], + 'downloadLimit' => [ + 'type' => 'Int', + 'description' => __( 'Number of times downloadable files can be downloaded.', 'wp-graphql-woocommerce' ), + ], + 'downloadExpiry' => [ + 'type' => 'Int', + 'description' => __( 'Number of days until the download expires.', 'wp-graphql-woocommerce' ), + ], + 'taxStatus' => [ + 'type' => 'TaxStatusEnum', + 'description' => __( 'Tax status of the product variation.', 'wp-graphql-woocommerce' ), + ], + 'taxClass' => [ + 'type' => 'String', + 'description' => __( 'Tax class of the product variation.', 'wp-graphql-woocommerce' ), + ], + 'manageStock' => [ + 'type' => 'String', + 'description' => __( 'Whether to manage stock. Either "yes", "no", or "parent".', 'wp-graphql-woocommerce' ), + ], + 'stockQuantity' => [ + 'type' => 'Int', + 'description' => __( 'Stock quantity.', 'wp-graphql-woocommerce' ), + ], + 'stockStatus' => [ + 'type' => 'StockStatusEnum', + 'description' => __( 'Stock status of the product variation.', 'wp-graphql-woocommerce' ), + ], + 'backorders' => [ + 'type' => 'BackordersEnum', + 'description' => __( 'Backorder status.', 'wp-graphql-woocommerce' ), + ], + 'weight' => [ + 'type' => 'String', + 'description' => __( 'Weight of the product variation.', 'wp-graphql-woocommerce' ), + ], + 'dimensions' => [ + 'type' => 'ProductDimensionsInput', + 'description' => __( 'Dimensions of the product variation.', 'wp-graphql-woocommerce' ), + ], + 'shippingClass' => [ + 'type' => 'String', + 'description' => __( 'Shipping class of the product variation.', 'wp-graphql-woocommerce' ), + ], + 'image' => [ + 'type' => 'ProductImageInput', + 'description' => __( 'Image of the product variation.', 'wp-graphql-woocommerce' ), + ], + 'attributes' => [ + 'type' => [ 'list_of' => 'ProductAttributeInput' ], + 'description' => __( 'Attributes of the product variation.', 'wp-graphql-woocommerce' ), + ], + 'menuOrder' => [ + 'type' => 'Int', + 'description' => __( 'Menu order of the product variation.', 'wp-graphql-woocommerce' ), + ], + 'metaData' => [ + 'type' => [ 'list_of' => 'MetaDataInput' ], + 'description' => __( 'Meta data of the product variation.', 'wp-graphql-woocommerce' ), + ], + ]; + } + + /** + * Defines the mutation output field configuration + * + * @return array + */ + public static function get_output_fields() { + return [ + 'variation' => [ + 'type' => 'ProductVariation', + 'resolve' => static function ( $payload ) { + return new Product_Variation( $payload['id'] ); + }, + ], + ]; + } + + /** + * Defines the mutation data modification closure. + * + * @param array $input Mutation input. + * @param \WPGraphQL\AppContext $context AppContext instance. + * @param \GraphQL\Type\Definition\ResolveInfo $info ResolveInfo instance. Can be + * use to get info about the current node in the GraphQL tree. + * + * @throws \GraphQL\Error\UserError Invalid ID provided | Lack of capabilities. + * + * @return array + */ + public static function mutate_and_get_payload( $input, AppContext $context, ResolveInfo $info ) { + if ( ! empty( $input['id'] ) ) { + /** + * @var \WC_Product_Variation $variation + */ + $variation = \wc_get_product( $input['id'] ); + } else { + $variation = new \WC_Product_Variation(); + } + + if ( 0 === $variation->get_parent_id() ) { + $variation->set_parent_id( $input['productId'] ); + } + + if ( isset( $input['visible'] ) ) { + $variation->set_status( false === $input['visible'] ? 'private' : 'publish' ); + } + + if ( ! empty( $input['sku'] ) ) { + /** + * @var string $sku + */ + $sku = wc_clean( $input['sku'] ); + $variation->set_sku( $sku ); + } + + if ( ! empty( $input['image'] ) ) { + $image = $input['image']; + $image['position'] = 0; + + $variation = Product_Mutation::set_product_images( $variation, [ $image ] ); + } else { + $variation->set_image_id( '' ); + } + + if ( isset( $input['virtual'] ) ) { + $variation->set_virtual( $input['virtual'] ); + } + + if ( isset( $input['downloadable'] ) ) { + $variation->set_downloadable( $input['downloadable'] ); + } + + if ( $variation->get_downloadable() ) { + if ( ! empty( $input['downloads'] ) ) { + $variation = Product_Mutation::save_downloadable_files( $variation, $input['downloads'] ); + } + + if ( ! empty( $input['downloadLimit'] ) ) { + $variation->set_download_limit( $input['downloadLimit'] ); + } + + if ( ! empty( $input['downloadExpiry'] ) ) { + $variation->set_download_expiry( $input['downloadExpiry'] ); + } + } + + $variation = Product_Mutation::save_product_shipping_data( $variation, $input ); + + if ( isset( $input['manageStock'] ) ) { + if ( 'parent' === $input['manageStock'] ) { + $variation->set_manage_stock( false ); + } else { + $variation->set_manage_stock( wc_string_to_bool( $input['manageStock'] ) ); + } + } + + if ( isset( $input['stockStatus'] ) ) { + $variation->set_stock_status( $input['stockStatus'] ); + } + + if ( isset( $input['backorders'] ) ) { + $variation->set_backorders( $input['backorders'] ); + } + + if ( $variation->get_manage_stock() ) { + if ( isset( $input['stockQuantity'] ) ) { + $variation->set_stock_quantity( $input['stockQuantity'] ); + } + } else { + $variation->set_backorders( 'no' ); + $variation->set_stock_quantity( null ); + } + + if ( isset( $input['regularPrice'] ) ) { + $variation->set_regular_price( $input['regularPrice'] ); + } + + if ( isset( $input['salePrice'] ) ) { + $variation->set_sale_price( $input['salePrice'] ); + } + + if ( isset( $input['dateOnSaleFrom'] ) ) { + $variation->set_date_on_sale_from( $input['dateOnSaleFrom'] ); + } + + if ( isset( $input['dateOnSaleTo'] ) ) { + $variation->set_date_on_sale_to( $input['dateOnSaleTo'] ); + } + + if ( isset( $input['taxClass'] ) ) { + $variation->set_tax_class( $input['taxClass'] ); + } + + if ( isset( $input['description'] ) ) { + $variation->set_description( $input['description'] ); + } + + if ( ! empty( $input['attributes'] ) ) { + $attributes = []; + $parent = wc_get_product( $variation->get_parent_id() ); + if ( ! $parent ) { + throw new UserError( __( 'Parent ID invalid', 'wp-graphql-woocommerce' ) ); + } + $parent_attributes = $parent->get_attributes(); + + foreach ( $input['attributes'] as $attribute ) { + /** + * Attribute ID. + * + * @var int $attribute_id + */ + $attribute_id = 0; + /** + * Attribute name. + * + * @var string $attribute_name + */ + $attribute_name = ''; + + // Check ID for global attributes or name for product attributes. + $raw_attribute_name = null; + if ( ! empty( $attribute['id'] ) ) { + $attribute_id = absint( $attribute['id'] ); + $raw_attribute_name = wc_attribute_taxonomy_name_by_id( $attribute_id ); + } elseif ( ! empty( $attribute['attributeName'] ) ) { + $raw_attribute_name = sanitize_title( $attribute['attributeName'] ); + } + + if ( ! $raw_attribute_name ) { + continue; + } + + $attribute_name = sanitize_title( $raw_attribute_name ); + + if ( ! isset( $parent_attributes[ $attribute_name ] ) || ! $parent_attributes[ $attribute_name ]->get_variation() ) { + continue; + } + + $attribute_key = sanitize_title( $parent_attributes[ $attribute_name ]->get_name() ); + /** + * @var string $attribute_value + */ + $attribute_value = isset( $attribute['attributeValue'] ) ? wc_clean( stripslashes( $attribute['attributeValue'] ) ) : ''; + + if ( $parent_attributes[ $attribute_name ]->is_taxonomy() ) { + // If dealing with a taxonomy, we need to get the slug from the name posted to the API. + $term = get_term_by( 'name', $attribute_value, $raw_attribute_name ); // @codingStandardsIgnoreLine + + if ( $term && ! is_wp_error( $term ) ) { + $attribute_value = $term->slug; + } else { + $attribute_value = sanitize_title( $attribute_value ); + } + } + + $attributes[ $attribute_key ] = $attribute_value; + } + + $variation->set_attributes( $attributes ); + } + + if ( ! empty( $input['menuOrder'] ) ) { + $variation->set_menu_order( $input['menuOrder'] ); + } + + if ( ! empty( $input['metaData'] ) ) { + foreach ( $input['metaData'] as $meta ) { + $variation->update_meta_data( $meta['key'], $meta['value'], isset( $meta['id'] ) ? $meta['id'] : '' ); + } + } + + $variation_id = $variation->save(); + + return [ 'id' => $variation_id ]; + } +} diff --git a/includes/mutation/class-product-variation-delete.php b/includes/mutation/class-product-variation-delete.php new file mode 100644 index 000000000..d70d7f3b4 --- /dev/null +++ b/includes/mutation/class-product-variation-delete.php @@ -0,0 +1,154 @@ + self::get_input_fields(), + 'outputFields' => self::get_output_fields(), + 'mutateAndGetPayload' => self::mutate_and_get_payload(), + ] + ); + } + + /** + * Defines the mutation input field configuration + * + * @return array + */ + public static function get_input_fields() { + return [ + 'id' => [ + 'type' => [ 'non_null' => 'ID' ], + 'description' => __( 'Unique identifier for the product.', 'wp-graphql-woocommerce' ), + ], + 'force' => [ + 'type' => 'Boolean', + 'description' => __( 'Whether to bypass trash and force deletion.', 'wp-graphql-woocommerce' ), + ], + ]; + } + + /** + * Defines the mutation output field configuration + * + * @return array + */ + public static function get_output_fields() { + return [ + 'variation' => [ + 'type' => 'ProductVariation', + 'resolve' => static function ( $payload ) { + return $payload['variation']; + }, + ], + ]; + } + + /** + * Defines the mutation data modification closure. + * + * @return callable + */ + public static function mutate_and_get_payload() { + return static function ( $input, AppContext $context, ResolveInfo $info ) { + $variation_id = $input['id']; + $force = isset( $input['force'] ) ? $input['force'] : false; + $object = new Product_Variation( $variation_id ); + $result = false; + + if ( 0 === $object->ID ) { + throw new UserError( __( 'Invalid product variation ID.', 'wp-graphql-woocommerce' ) ); + } + + $supports_trash = EMPTY_TRASH_DAYS > 0 && is_callable( [ $object, 'get_status' ] ); + + /** + * Filter whether an object is trashable. + * + * Return false to disable trash support for the object. + * + * @param boolean $supports_trash Whether the object type support trashing. + * @param \WPGraphQL\WooCommerce\Model\Product_Variation $object The object being considered for trashing support. + */ + $supports_trash = apply_filters( 'graphql_woocommerce_product_variation_object_trashable', $supports_trash, $object ); + + if ( ! wc_rest_check_post_permissions( 'product_variation', 'delete', $object->ID ) ) { + throw new UserError( __( 'Sorry, you are not allowed to delete product variations', 'wp-graphql-woocommerce' ) ); + } + + /** + * Get the variation to be deleted. + * + * @var \WC_Product_Variation $variation_to_be_deleted + */ + $variation_to_be_deleted = \wc_get_product( $object->ID ); + + if ( $force ) { + $variation_to_be_deleted->delete( true ); + $result = 0 === $variation_to_be_deleted->get_id(); + } else { + // If we don't support trashing for this type, error out. + if ( ! $supports_trash ) { + throw new UserError( __( 'This product variation does not support trashing.', 'wp-graphql-woocommerce' ) ); + } + + if ( is_callable( [ $variation_to_be_deleted, 'get_status' ] ) ) { + if ( 'trash' === $variation_to_be_deleted->get_status() ) { + throw new UserError( __( 'Product variation is already in the trash.', 'wp-graphql-woocommerce' ) ); + } + + $variation_to_be_deleted->delete(); + + /** + * @var string $status + */ + $status = $variation_to_be_deleted->get_status(); + $result = 'trash' === $status; + } + } + + if ( ! $result ) { + throw new UserError( __( 'Failed to delete product variation.', 'wp-graphql-woocommerce' ) ); + } + + if ( 0 !== $variation_to_be_deleted->get_parent_id() ) { + \wc_delete_product_transients( $variation_to_be_deleted->get_parent_id() ); + } + + /** + * Fires after a single object is deleted or trashed via the REST API. + * + * @param \WPGraphQL\WooCommerce\Model\Product_Variation $object The deleted or trashed object. + * @param array $input The mutation input. + */ + do_action( 'graphql_woocommerce_delete_product_variation_object', $object, $input ); + + return [ 'variation' => $object ]; + }; + } +} diff --git a/includes/mutation/class-product-variation-update.php b/includes/mutation/class-product-variation-update.php new file mode 100644 index 000000000..46c8433b9 --- /dev/null +++ b/includes/mutation/class-product-variation-update.php @@ -0,0 +1,67 @@ + self::get_input_fields(), + 'outputFields' => self::get_output_fields(), + 'mutateAndGetPayload' => [ Product_Variation_Create::class, 'mutate_and_get_payload' ], + ] + ); + } + + /** + * Defines the mutation input field configuration + * + * @return array + */ + public static function get_input_fields() { + return array_merge( + [ + 'id' => [ + 'type' => [ 'non_null' => 'ID' ], + 'description' => __( 'Unique identifier for the product.', 'wp-graphql-woocommerce' ), + ], + ], + Product_Variation_Create::get_input_fields() + ); + } + + /** + * Defines the mutation output field configuration + * + * @return array + */ + public static function get_output_fields() { + return [ + 'variation' => [ + 'type' => 'ProductVariation', + 'resolve' => static function ( $payload ) { + return new Product_Variation( $payload['id'] ); + }, + ], + ]; + } +} diff --git a/includes/type/input/class-product-attribute-input.php b/includes/type/input/class-product-attribute-input.php index 2ef5e1a4d..d865a8058 100644 --- a/includes/type/input/class-product-attribute-input.php +++ b/includes/type/input/class-product-attribute-input.php @@ -29,6 +29,9 @@ public static function register() { 'attributeValue' => [ 'type' => 'String', ], + 'id' => [ + 'type' => 'Int', + ], ], ] ); diff --git a/includes/type/input/class-product-attributes-input.php b/includes/type/input/class-product-attributes-input.php new file mode 100644 index 000000000..695f40700 --- /dev/null +++ b/includes/type/input/class-product-attributes-input.php @@ -0,0 +1,54 @@ + __( 'Product attribute properties', 'wp-graphql-woocommerce' ), + 'fields' => [ + 'id' => [ + 'type' => 'Int', + 'description' => __( 'Attribute ID', 'wp-graphql-woocommerce' ), + ], + 'name' => [ + 'type' => [ 'non_null' => 'String' ], + 'description' => __( 'Attribute name', 'wp-graphql-woocommerce' ), + ], + 'position' => [ + 'type' => 'Int', + 'description' => __( 'Attribute position', 'wp-graphql-woocommerce' ), + ], + 'visible' => [ + 'type' => 'Boolean', + 'description' => __( 'Define if the attribute is visible on the "Additional information" tab in the product\'s page. Default is false.', 'wp-graphql-woocommerce' ), + ], + 'variation' => [ + 'type' => 'Boolean', + 'description' => __( 'Define if the attribute can be used as variation. Default is false.', 'wp-graphql-woocommerce' ), + ], + 'options' => [ + 'type' => [ 'list_of' => 'String' ], + 'description' => __( 'List of available term names for the attribute', 'wp-graphql-woocommerce' ), + ], + ], + ] + ); + } +} diff --git a/includes/type/input/class-product-dimensions-input.php b/includes/type/input/class-product-dimensions-input.php new file mode 100644 index 000000000..5608f34d2 --- /dev/null +++ b/includes/type/input/class-product-dimensions-input.php @@ -0,0 +1,42 @@ + __( 'Product dimensions', 'wp-graphql-woocommerce' ), + 'fields' => [ + 'length' => [ + 'type' => 'String', + 'description' => __( 'Length of the product', 'wp-graphql-woocommerce' ), + ], + 'width' => [ + 'type' => 'String', + 'description' => __( 'Width of the product', 'wp-graphql-woocommerce' ), + ], + 'height' => [ + 'type' => 'String', + 'description' => __( 'Height of the product', 'wp-graphql-woocommerce' ), + ], + ], + ] + ); + } +} diff --git a/includes/type/input/class-product-download-input.php b/includes/type/input/class-product-download-input.php new file mode 100644 index 000000000..957b31309 --- /dev/null +++ b/includes/type/input/class-product-download-input.php @@ -0,0 +1,42 @@ + __( 'Product download', 'wp-graphql-woocommerce' ), + 'fields' => [ + 'id' => [ + 'type' => 'Int', + 'description' => __( 'File ID', 'wp-graphql-woocommerce' ), + ], + 'name' => [ + 'type' => [ 'non_null' => 'String' ], + 'description' => __( 'File name', 'wp-graphql-woocommerce' ), + ], + 'file' => [ + 'type' => [ 'non_null' => 'String' ], + 'description' => __( 'File URL', 'wp-graphql-woocommerce' ), + ], + ], + ] + ); + } +} diff --git a/includes/type/input/class-product-image-input.php b/includes/type/input/class-product-image-input.php new file mode 100644 index 000000000..40dd9b626 --- /dev/null +++ b/includes/type/input/class-product-image-input.php @@ -0,0 +1,46 @@ + __( 'Product image', 'wp-graphql-woocommerce' ), + 'fields' => [ + 'id' => [ + 'type' => 'Int', + 'description' => __( 'Image ID', 'wp-graphql-woocommerce' ), + ], + 'src' => [ + 'type' => 'String', + 'description' => __( 'Image URL', 'wp-graphql-woocommerce' ), + ], + 'name' => [ + 'type' => 'String', + 'description' => __( 'Image name', 'wp-graphql-woocommerce' ), + ], + 'altText' => [ + 'type' => 'String', + 'description' => __( 'Image alternative text', 'wp-graphql-woocommerce' ), + ], + ], + ] + ); + } +} diff --git a/includes/type/object/class-product-attribute-object-type.php b/includes/type/object/class-product-attribute-object-type.php new file mode 100644 index 000000000..03d603f86 --- /dev/null +++ b/includes/type/object/class-product-attribute-object-type.php @@ -0,0 +1,75 @@ + __( 'Product attribute object.', 'wp-graphql-woocommerce' ), + 'eagerlyLoadType' => true, + 'fields' => [ + 'id' => [ + 'type' => 'ID', + 'description' => __( 'Unique identifier for the product attribute.', 'wp-graphql-woocommerce' ), + 'resolve' => static function ( $source ) { + return ! empty( $source->attribute_id ) ? $source->attribute_id : null; + }, + ], + 'name' => [ + 'type' => 'String', + 'description' => __( 'Name of the attribute.', 'wp-graphql-woocommerce' ), + 'resolve' => static function ( $source ) { + return ! empty( $source->attribute_name ) ? (string) $source->attribute_name : null; + }, + ], + 'label' => [ + 'type' => 'String', + 'description' => __( 'Label of the attribute.', 'wp-graphql-woocommerce' ), + 'resolve' => static function ( $source ) { + return ! empty( $source->attribute_label ) ? (string) $source->attribute_label : null; + }, + ], + 'type' => [ + 'type' => 'String', + 'description' => __( 'Type of the attribute.', 'wp-graphql-woocommerce' ), + 'resolve' => static function ( $source ) { + return ! empty( $source->attribute_type ) ? (string) $source->attribute_type : null; + }, + ], + 'orderBy' => [ + 'type' => 'String', + 'description' => __( 'Order by which the attribute should be sorted.', 'wp-graphql-woocommerce' ), + 'resolve' => static function ( $source ) { + return ! empty( $source->attribute_orderby ) ? (string) $source->attribute_orderby : null; + }, + ], + 'hasArchives' => [ + 'type' => 'Boolean', + 'description' => __( 'Whether or not the attribute has archives.', 'wp-graphql-woocommerce' ), + 'resolve' => static function ( $source ) { + return isset( $source->attribute_public ) ? $source->attribute_public : false; + }, + ], + ], + ] + ); + } +} diff --git a/includes/type/object/class-product-attribute-term-object-type.php b/includes/type/object/class-product-attribute-term-object-type.php new file mode 100644 index 000000000..6f14b624e --- /dev/null +++ b/includes/type/object/class-product-attribute-term-object-type.php @@ -0,0 +1,75 @@ + __( 'Product attribute object.', 'wp-graphql-woocommerce' ), + 'eagerlyLoadType' => true, + 'fields' => [ + 'id' => [ + 'type' => 'Integer', + 'description' => __( 'Unique identifier for the product attribute.', 'wp-graphql-woocommerce' ), + 'resolve' => static function ( $source ) { + return ! empty( $source->id ) ? $source->id : null; + }, + ], + 'name' => [ + 'type' => 'String', + 'description' => __( 'Name of the attribute.', 'wp-graphql-woocommerce' ), + 'resolve' => static function ( $source ) { + return ! empty( $source->name ) ? $source->name : null; + }, + ], + 'slug' => [ + 'type' => 'String', + 'description' => __( 'Label of the attribute.', 'wp-graphql-woocommerce' ), + 'resolve' => static function ( $source ) { + return ! empty( $source->slug ) ? $source->slug : null; + }, + ], + 'description' => [ + 'type' => 'String', + 'description' => __( 'Type of the attribute.', 'wp-graphql-woocommerce' ), + 'resolve' => static function ( $source ) { + return ! empty( $source->description ) ? $source->description : null; + }, + ], + 'menuOrder' => [ + 'type' => 'Integer', + 'description' => __( 'Order by which the attribute should be sorted.', 'wp-graphql-woocommerce' ), + 'resolve' => static function ( $source ) { + return isset( $source->menu_order ) ? $source->menu_order : 0; + }, + ], + 'count' => [ + 'type' => 'Integer', + 'description' => __( 'Whether or not the attribute has archives.', 'wp-graphql-woocommerce' ), + 'resolve' => static function ( $source ) { + return isset( $source->count ) ? $source->count : 0; + }, + ], + ], + ] + ); + } +} diff --git a/phpstan/constants.php b/phpstan/constants.php index 6833eeb73..1e2b7acc9 100644 --- a/phpstan/constants.php +++ b/phpstan/constants.php @@ -11,3 +11,4 @@ define( 'WPGRAPHQL_WOOCOMMERCE_PLUGIN_DIR', '' ); define( 'WPGRAPHQL_WOOCOMMERCE_PLUGIN_URL', '' ); define( 'WC_SESSION_CACHE_GROUP', '' ); +define( 'WC_DELIMITER', '|' ); diff --git a/tests/_data/test-product.jpg b/tests/_data/test-product.jpg new file mode 100644 index 000000000..6c7b4d54f Binary files /dev/null and b/tests/_data/test-product.jpg differ diff --git a/tests/_support/Factory/ProductFactory.php b/tests/_support/Factory/ProductFactory.php index dc7d816c1..7f763835f 100644 --- a/tests/_support/Factory/ProductFactory.php +++ b/tests/_support/Factory/ProductFactory.php @@ -91,6 +91,15 @@ public function createSimple( $args = [] ) { return $this->create( $args, $generation_definitions ); } + public function createManySimple( $count = 5, $args = []) { + $products = []; + for ( $i = 0; $i < $count; $i++ ) { + $products[] = $this->createSimple( $args ); + } + + return $products; + } + public function createExternal( $args = [] ) { $name = Dummy::instance()->product(); $price = Dummy::instance()->price( 15, 200 ); @@ -174,6 +183,8 @@ public function createAttribute( $raw_name = 'size', $terms = [ 'small' ] ) { if ( ! $attribute_id ) { $taxonomy_name = wc_attribute_taxonomy_name( $attribute_name ); + unregister_taxonomy( $taxonomy_name ); + $attribute_id = wc_create_attribute( [ 'name' => $raw_name, @@ -184,6 +195,11 @@ public function createAttribute( $raw_name = 'size', $terms = [ 'small' ] ) { ] ); + if ( is_wp_error( $attribute_id ) ) { + codecept_debug( json_encode( $attribute_id, JSON_PRETTY_PRINT ) ); + throw new \Exception( 'Failed to create attribute.' ); + } + // Register as taxonomy. register_taxonomy( $taxonomy_name, @@ -232,6 +248,19 @@ public function createAttribute( $raw_name = 'size', $terms = [ 'small' ] ) { return $return; } + public function createAttributeObject( string $id, string $taxonomy, array $options, int $position = 0, bool $visible = true, bool $variation = false ) { + $attribute = new \WC_Product_Attribute(); + + $attribute->set_id( $id ); + $attribute->set_name( $taxonomy ); + $attribute->set_options( $options ); + $attribute->set_position( $position ); + $attribute->set_visible( $visible ); + $attribute->set_variation( $variation ); + + return $attribute; + } + private function setVariationAttributes( \WC_Product_Variable $product, array $attribute_data = [] ) { $attributes = []; foreach ( $attribute_data as $index => $data ) { @@ -374,4 +403,12 @@ private function slugify( $text ) { return $text; } + + public function deleteAttributes() { + global $wpdb; + + $wpdb->query( + "DELETE FROM {$wpdb->prefix}woocommerce_attribute_taxonomies" + ); + } } diff --git a/tests/_support/TestCase/WooGraphQLTestCase.php b/tests/_support/TestCase/WooGraphQLTestCase.php index 0019ac790..541dcf0ca 100644 --- a/tests/_support/TestCase/WooGraphQLTestCase.php +++ b/tests/_support/TestCase/WooGraphQLTestCase.php @@ -69,6 +69,7 @@ public function setUp(): void { public function tearDown(): void { \WC()->cart->empty_cart( true ); + $this->factory->product->deleteAttributes(); // then parent::tearDown(); diff --git a/tests/wpunit/ProductAttributeMutationsTest.php b/tests/wpunit/ProductAttributeMutationsTest.php new file mode 100644 index 000000000..b95497d73 --- /dev/null +++ b/tests/wpunit/ProductAttributeMutationsTest.php @@ -0,0 +1,223 @@ + [ + 'name' => 'Pattern', + 'slug' => 'pattern', + 'orderBy' => 'menu_order', + 'hasArchives' => false, + ], + ]; + + // Assert mutation fails as unauthenticated user. + $response = $this->graphql( compact( 'query', 'variables' ) ); + $this->assertQueryError( $response ); + + + // Assert mutation fails as authenticated user without proper capabilities. + $this->loginAsCustomer(); + $response = $this->graphql( compact( 'query', 'variables' ) ); + $this->assertQueryError( $response ); + + // Assert mutation succeeds as authenticated user with proper capabilities. + $this->loginAsShopManager(); + $response = $this->graphql( compact( 'query', 'variables' ) ); + $expected = [ + $this->expectedObject( + 'createProductAttribute.attribute', + [ + $this->expectedField( 'id', self::NOT_NULL ), + $this->expectedField( 'name', 'pattern' ), + $this->expectedField( 'label', 'Pattern' ), + $this->expectedField( 'type', 'select' ), + $this->expectedField( 'orderBy', 'menu_order' ), + $this->expectedField( 'hasArchives', false ), + ] + ), + ]; + $this->assertQuerySuccessful( $response, $expected ); + } + + public function testUpdateProductAttribute() { + $query = ' + mutation ($input: CreateProductAttributeInput!) { + createProductAttribute(input: $input) { + attribute { + id + name + label + type + orderBy + hasArchives + } + } + } + '; + + $variables = [ + 'input' => [ + 'name' => 'Pattern', + 'slug' => 'pattern', + 'orderBy' => 'menu_order', + 'hasArchives' => false, + ], + ]; + + $this->loginAsShopManager(); + $response = $this->graphql( compact( 'query', 'variables' ) ); + $expected = [ + $this->expectedObject( + 'createProductAttribute.attribute', + [ + $this->expectedField( 'id', self::NOT_NULL ), + $this->expectedField( 'hasArchives', false ), + ] + ), + ]; + $this->assertQuerySuccessful( $response, $expected ); + + $attribute_id = $this->lodashGet( $response, 'data.createProductAttribute.attribute.id' ); + $this->assertNotEmpty( $attribute_id ); + + $query = ' + mutation ($input: UpdateProductAttributeInput!) { + updateProductAttribute(input: $input) { + attribute { + id + name + label + type + orderBy + hasArchives + } + } + } + '; + + $variables = [ + 'input' => [ + 'id' => $attribute_id, + 'name' => 'Pattern', + 'slug' => 'pattern', + 'orderBy' => 'menu_order', + 'hasArchives' => true, + ], + ]; + + // Assert mutation fails as unauthenticated user. + $this->loginAs( 0 ); + $response = $this->graphql( compact( 'query', 'variables' ) ); + $this->assertQueryError( $response ); + + + // Assert mutation fails as authenticated user without proper capabilities. + $this->loginAsCustomer(); + $response = $this->graphql( compact( 'query', 'variables' ) ); + $this->assertQueryError( $response ); + + // Assert mutation succeeds as authenticated user with proper capabilities. + $this->loginAsShopManager(); + $response = $this->graphql( compact( 'query', 'variables' ) ); + $expected = [ + $this->expectedObject( + 'updateProductAttribute.attribute', + [ + $this->expectedField( 'id', $attribute_id ), + $this->expectedField( 'name', 'pattern' ), + $this->expectedField( 'label', 'Pattern' ), + $this->expectedField( 'type', 'select' ), + $this->expectedField( 'orderBy', 'menu_order' ), + $this->expectedField( 'hasArchives', true ), + ] + ), + ]; + + $this->assertQuerySuccessful( $response, $expected ); + } + + public function testDeleteProductAttribute() { + $query = ' + mutation ($input: CreateProductAttributeInput!) { + createProductAttribute(input: $input) { + attribute { + id + } + } + } + '; + + $variables = [ + 'input' => [ + 'name' => 'Pattern', + 'slug' => 'pattern', + 'orderBy' => 'menu_order', + 'hasArchives' => false, + ], + ]; + + $this->loginAsShopManager(); + $response = $this->graphql( compact( 'query', 'variables' ) ); + $this->assertQuerySuccessful( $response ); + + $attribute_id = $this->lodashGet( $response, 'data.createProductAttribute.attribute.id' ); + $this->assertNotEmpty( $attribute_id ); + + $query = ' + mutation ($input: DeleteProductAttributeInput!) { + deleteProductAttribute(input: $input) { + attribute { + id + } + } + } + '; + + $variables = [ + 'input' => [ + 'id' => $attribute_id, + ], + ]; + + // Assert mutation fails as unauthenticated user. + $this->loginAs( 0 ); + $response = $this->graphql( compact( 'query', 'variables' ) ); + $this->assertQueryError( $response ); + + + // Assert mutation fails as authenticated user without proper capabilities. + $this->loginAsCustomer(); + $response = $this->graphql( compact( 'query', 'variables' ) ); + $this->assertQueryError( $response ); + + // Assert mutation succeeds as authenticated user with proper capabilities. + $this->loginAsShopManager(); + $response = $this->graphql( compact( 'query', 'variables' ) ); + $expected = [ + $this->expectedObject( + 'deleteProductAttribute.attribute', + [ + $this->expectedField( 'id', $attribute_id ), + ] + ), + ]; + + $this->assertQuerySuccessful( $response, $expected ); + } +} diff --git a/tests/wpunit/ProductAttributeTermMutationsTest.php b/tests/wpunit/ProductAttributeTermMutationsTest.php new file mode 100644 index 000000000..129911d4b --- /dev/null +++ b/tests/wpunit/ProductAttributeTermMutationsTest.php @@ -0,0 +1,169 @@ +factory->product->createAttribute( 'kind', [ 'normal', 'special' ] ); + + $query = ' + mutation ($input: CreateProductAttributeTermInput!) { + createProductAttributeTerm(input: $input) { + term { + id + name + slug + description + menuOrder + count + } + } + } + '; + + $variables = [ + 'input' => [ + 'attributeId' => $kind_attribute['attribute_id'], + 'name' => 'Hated', + 'slug' => 'hated', + 'description' => 'Hated by all', + 'menuOrder' => 2, + ], + ]; + + // Assert mutation fails as unauthenticated user. + $response = $this->graphql( compact( 'query', 'variables' ) ); + $this->assertQueryError( $response ); + + + // Assert mutation fails as authenticated user without proper capabilities. + $this->loginAsCustomer(); + $response = $this->graphql( compact( 'query', 'variables' ) ); + $this->assertQueryError( $response ); + + // Assert mutation succeeds as authenticated user with proper capabilities. + $this->loginAsShopManager(); + $response = $this->graphql( compact( 'query', 'variables' ) ); + $expected = [ + $this->expectedObject( + 'createProductAttributeTerm.term', + [ + $this->expectedField( 'id', self::NOT_NULL ), + $this->expectedField( 'name', 'Hated' ), + $this->expectedField( 'slug', 'hated' ), + $this->expectedField( 'description', 'Hated by all' ), + $this->expectedField( 'menuOrder', 2 ), + $this->expectedField( 'count', 0 ), + ] + ) + ]; + + $this->assertQuerySuccessful( $response, $expected ); + } + + public function testUpdateProductAttributeTerm() { + $kind_attribute = $this->factory->product->createAttribute( 'kind', [ 'normal', 'special', 'hated' ] ); + $hated_term_id = get_term_by( 'slug', 'hated', 'pa_kind' )->term_id; + + $query = ' + mutation ($input: UpdateProductAttributeTermInput!) { + updateProductAttributeTerm(input: $input) { + term { + id + name + slug + description + menuOrder + count + } + } + } + '; + + $variables = [ + 'input' => [ + 'attributeId' => $kind_attribute['attribute_id'], + 'id' => $hated_term_id, + 'name' => 'Loved', + 'slug' => 'loved', + 'description' => 'Loved by all', + 'menuOrder' => 0, + ], + ]; + + // Assert mutation fails as unauthenticated user. + $response = $this->graphql( compact( 'query', 'variables' ) ); + $this->assertQueryError( $response ); + + + // Assert mutation fails as authenticated user without proper capabilities. + $this->loginAsCustomer(); + $response = $this->graphql( compact( 'query', 'variables' ) ); + $this->assertQueryError( $response ); + + // Assert mutation succeeds as authenticated user with proper capabilities. + $this->loginAsShopManager(); + $response = $this->graphql( compact( 'query', 'variables' ) ); + $expected = [ + $this->expectedObject( + 'updateProductAttributeTerm.term', + [ + $this->expectedField( 'id', self::NOT_NULL ), + $this->expectedField( 'name', 'Loved' ), + $this->expectedField( 'slug', 'loved' ), + $this->expectedField( 'description', 'Loved by all' ), + $this->expectedField( 'menuOrder', 0 ), + $this->expectedField( 'count', 0 ), + ] + ) + ]; + + $this->assertQuerySuccessful( $response, $expected ); + } + + public function testDeleteProductAttributeTerm() { + $kind_attribute = $this->factory->product->createAttribute( 'kind', [ 'normal', 'special', 'hated' ] ); + $hated_term_id = get_term_by( 'slug', 'hated', 'pa_kind' )->term_id; + + $query = ' + mutation ($input: DeleteProductAttributeTermInput!) { + deleteProductAttributeTerm(input: $input) { + term { + id + slug + } + } + } + '; + + $variables = [ + 'input' => [ + 'attributeId' => $kind_attribute['attribute_id'], + 'id' => $hated_term_id, + ], + ]; + + // Assert mutation fails as unauthenticated user. + $response = $this->graphql( compact( 'query', 'variables' ) ); + $this->assertQueryError( $response ); + + + // Assert mutation fails as authenticated user without proper capabilities. + $this->loginAsCustomer(); + $response = $this->graphql( compact( 'query', 'variables' ) ); + $this->assertQueryError( $response ); + + // Assert mutation succeeds as authenticated user with proper capabilities. + $this->loginAsShopManager(); + $response = $this->graphql( compact( 'query', 'variables' ) ); + $expected = [ + $this->expectedObject( + 'deleteProductAttributeTerm.term', + [ + $this->expectedField( 'id', self::NOT_NULL ), + $this->expectedField( 'slug', 'hated' ), + ] + ) + ]; + + $this->assertQuerySuccessful( $response, $expected ); + } +} diff --git a/tests/wpunit/ProductMutationsTest.php b/tests/wpunit/ProductMutationsTest.php new file mode 100644 index 000000000..2f4cecab7 --- /dev/null +++ b/tests/wpunit/ProductMutationsTest.php @@ -0,0 +1,826 @@ + [ + 'name' => 'Test Product', + 'type' => 'SIMPLE', + 'regularPrice' => 10, + 'salePrice' => 7, + 'dateOnSaleFrom' => date( 'Y-m-d H:i:s', $yesterday ), + 'dateOnSaleTo' => date( 'Y-m-d H:i:s', $tomorrow ), + 'manageStock' => true, + 'stockQuantity' => 3, + 'soldIndividually' => true, + 'dimensions' => [ + 'length' => '40cm', + 'width' => '5in', + 'height' => '2ft', + ], + 'purchaseNote' => 'Test note', + 'taxStatus' => 'TAXABLE', + 'taxClass' => 'STANDARD', + 'reviewsAllowed' => true, + ] + ]; + + // Assert mutation fails as unauthenticated user. + $response = $this->graphql( compact( 'query', 'variables' ) ); + $this->assertQueryError( $response ); + + + // Assert mutation fails as authenticated user without proper capabilities. + $this->loginAsCustomer(); + $response = $this->graphql( compact( 'query', 'variables' ) ); + $this->assertQueryError( $response ); + + // Assert mutation succeeds as authenticated user with proper capabilities. + $this->loginAsShopManager(); + $response = $this->graphql( compact( 'query', 'variables' ) ); + $expected = [ + $this->expectedObject( + 'createProduct.product', + [ + $this->expectedField( 'id', self::NOT_NULL ), + $this->expectedField( 'name', 'Test Product' ), + $this->expectedField( 'type', 'SIMPLE' ), + $this->expectedField( 'price', "$7.00" ), + $this->expectedField( 'regularPrice', "$10.00" ), + $this->expectedField( 'salePrice', "$7.00" ), + $this->expectedField( 'dateOnSaleFrom', date( 'Y-m-d\TH:i:sP', $yesterday ) ), + $this->expectedField( 'dateOnSaleTo', date( 'Y-m-d\TH:i:sP', $tomorrow ) ), + $this->expectedField( 'stockQuantity', 3 ), + $this->expectedField( 'stockStatus', 'IN_STOCK' ), + $this->expectedField( 'soldIndividually', true ), + $this->expectedField( 'length', '40' ), + $this->expectedField( 'width', '5' ), + $this->expectedField( 'height', '2' ), + $this->expectedField( 'purchaseNote', 'Test note' ), + $this->expectedField( 'taxStatus', 'TAXABLE' ), + $this->expectedField( 'taxClass', 'STANDARD' ), + $this->expectedField( 'shippingRequired', true ), + $this->expectedField( 'shippingTaxable', true ), + $this->expectedField( 'reviewsAllowed', true ), + ] + ), + ]; + + $this->assertQuerySuccessful( $response, $expected ); + } + + public function testCreateProductWithImages() { + // Create images. + $image_ids = array_map( + function( $id ) { + return $this->factory->attachment->create_upload_object( + __DIR__ . '/../_data/test-product.jpg', + $id + ); + }, + $this->factory->attachment->create_many( + 5, + [ + 'post_mime_type' => 'image/gif', + 'post_author' => $this->admin, + ] + ) + ); + + // Create query. + $query = ' + mutation ( $input: CreateProductInput! ) { + createProduct(input: $input) { + product { + id + name + type + image { id } + galleryImages { nodes { id } } + } + } + } + '; + + // Create variables. + $variables = [ + 'input' => [ + 'name' => 'Test Product', + 'type' => 'SIMPLE', + 'regularPrice' => 10, + ] + ]; + $variables['input']['images'] = array_map( + function( $id ) { + return [ 'id' => $id ]; + }, + $image_ids + ); + + // Run mutation. + $this->loginAsShopManager(); + $response = $this->graphql( compact( 'query', 'variables' ) ); + + // Assert response. + $expected = [ + $this->expectedObject( + 'createProduct.product', + [ + $this->expectedField( 'id', self::NOT_NULL ), + $this->expectedField( 'name', 'Test Product' ), + $this->expectedField( 'type', 'SIMPLE' ), + $this->expectedField( 'image.id', $this->toRelayId( 'post', $image_ids[0] ) ), + $this->expectedObject( + 'galleryImages', + [ + $this->expectedField( 'nodes.#.id', $this->toRelayId( 'post', $image_ids[1] ) ), + $this->expectedField( 'nodes.#.id', $this->toRelayId( 'post', $image_ids[2] ) ), + $this->expectedField( 'nodes.#.id', $this->toRelayId( 'post', $image_ids[3] ) ), + $this->expectedField( 'nodes.#.id', $this->toRelayId( 'post', $image_ids[4] ) ), + ] + ), + ] + ), + ]; + + $this->assertQuerySuccessful( $response, $expected ); + } + + public function testCreateProductWithMetaData() { + $query = ' + mutation ( $input: CreateProductInput! ) { + createProduct(input: $input) { + product { + id + name + type + metaData { + id + key + value + } + } + } + } + '; + + $this->loginAsShopManager(); + $variables = [ + 'input' => [ + 'name' => 'Test Product', + 'type' => 'SIMPLE', + 'regularPrice' => 10, + 'metaData' => [ + [ + 'key' => 'test_key', + 'value' => 'test_value', + ], + [ + 'key' => 'test_key_2', + 'value' => 'test_value_2', + ], + ], + ] + ]; + + $response = $this->graphql( compact( 'query', 'variables' ) ); + $expected = [ + $this->expectedObject( + 'createProduct.product', + [ + $this->expectedField( 'id', self::NOT_NULL ), + $this->expectedField( 'name', 'Test Product' ), + $this->expectedField( 'type', 'SIMPLE' ), + $this->expectedNode( + 'metaData', + [ + $this->expectedField( 'id', self::NOT_NULL ), + $this->expectedField( 'key', 'test_key' ), + $this->expectedField( 'value', 'test_value' ), + ], + ), + $this->expectedNode( + 'metaData', + [ + $this->expectedField( 'id', self::NOT_NULL ), + $this->expectedField( 'key', 'test_key_2' ), + $this->expectedField( 'value', 'test_value_2' ), + ], + ), + ] + ), + ]; + + $this->assertQuerySuccessful( $response, $expected ); + } + + public function testCreateProductWithTaxonomies() { + // Create categories and tags. + $clothing_id = $this->factory->product->createProductCategory( 'clothing' ); + $shirts_id = $this->factory->product->createProductCategory( 'shirts', $clothing_id ); + $new_id = $this->factory->product->createProductTag( 'new' ); + + // Create query. + $query = ' + mutation ( $input: CreateProductInput! ) { + createProduct(input: $input) { + product { + id + name + type + productCategories { nodes { name } } + productTags { nodes { name } } + } + } + } + '; + + $this->loginAsShopManager(); + $variables = [ + 'input' => [ + 'name' => 'Test Product', + 'type' => 'SIMPLE', + 'regularPrice' => 10, + 'categories' => [ $shirts_id ], + 'tags' => [ $new_id ], + ] + ]; + $response = $this->graphql( compact( 'query', 'variables' ) ); + $expected = [ + $this->expectedObject( + 'createProduct.product', + [ + $this->expectedField( 'id', self::NOT_NULL ), + $this->expectedField( 'name', 'Test Product' ), + $this->expectedField( 'type', 'SIMPLE' ), + $this->expectedField( 'productCategories.nodes.#.name', 'shirts' ), + $this->expectedField( 'productTags.nodes.#.name', 'new' ), + $this->not()->expectedField( 'productCategories.nodes.#.id', 'clothing' ), + ] + ), + ]; + + $this->assertQuerySuccessful( $response, $expected ); + } + + public function testCreateProductWithUpsellAndCrossSell() { + $upsell_ids = $this->factory->product->createManySimple(5); + $cross_sell_ids = $this->factory->product->createManySimple(5); + + $query = ' + mutation ( $input: CreateProductInput! ) { + createProduct(input: $input) { + product { + id + name + type + upsell { nodes { id } } + ... on SimpleProduct { + crossSell { nodes { id } } + } + } + } + } + '; + + $this->loginAsShopManager(); + $variables = [ + 'input' => [ + 'name' => 'Test Product', + 'type' => 'SIMPLE', + 'regularPrice' => 10, + 'upsellIds' => $upsell_ids, + 'crossSellIds' => $cross_sell_ids, + ] + ]; + $response = $this->graphql( compact( 'query', 'variables' ) ); + $expected = [ + $this->expectedObject( + 'createProduct.product', + array_merge( + [ + $this->expectedField( 'id', self::NOT_NULL ), + $this->expectedField( 'name', 'Test Product' ), + $this->expectedField( 'type', 'SIMPLE' ), + ], + array_map( + function( $id ) { + return $this->expectedField( 'upsell.nodes.#.id', $this->toRelayId( 'product', $id ) ); + }, + $upsell_ids + ), + array_map( + function( $id ) { + return $this->expectedField( 'crossSell.nodes.#.id', $this->toRelayId( 'product', $id ) ); + }, + $cross_sell_ids + ) + ) + ), + ]; + + $this->assertQuerySuccessful( $response, $expected ); + } + + public function testCreateProductWithAttributes() { + $query = ' + mutation ( $input: CreateProductInput! ) { + createProduct(input: $input) { + product { + id + name + type + attributes { + nodes { + id + name + label + options + } + } + } + } + } + '; + + $kind_attribute = $this->factory->product->createAttribute( 'kind', [ 'normal', 'special' ] ); + + $this->loginAsShopManager(); + $variables = [ + 'input' => [ + 'name' => 'Test Product', + 'type' => 'SIMPLE', + 'regularPrice' => 10, + 'attributes' => [ + [ + 'id' => $kind_attribute['attribute_id'], + 'name' => $kind_attribute['attribute_name'], + 'options' => [ 'special' ], + ], + ], + ] + ]; + $response = $this->graphql( compact( 'query', 'variables' ) ); + $expected = [ + $this->expectedObject( + 'createProduct.product', + [ + $this->expectedField( 'id', self::NOT_NULL ), + $this->expectedField( 'name', 'Test Product' ), + $this->expectedField( 'type', 'SIMPLE' ), + $this->expectedObject( + 'attributes', + [ + $this->expectedNode( + 'nodes', + [ + $this->expectedField( 'id', self::NOT_NULL ), + $this->expectedField( 'name', 'pa_kind' ), + $this->expectedField( 'label', 'Kind' ), + $this->expectedField( 'options', [ 'special' ] ), + ], + 0 + ), + ] + ), + ] + ), + ]; + + $this->assertQuerySuccessful( $response, $expected ); + } + + public function testCreatingVariableProductWithCreateProduct() { + $query = ' + mutation ( $input: CreateProductInput! ) { + createProduct(input: $input) { + product { + id + name + type + ... on VariableProduct { + attributes { + nodes { + id + name + label + options + variation + scope + } + } + defaultAttributes { + nodes { + id + name + label + value + } + } + } + } + } + } + '; + + $kind_attribute = $this->factory->product->createAttribute( 'kind', [ 'normal', 'special' ] ); + + $this->loginAsShopManager(); + $variables = [ + 'input' => [ + 'name' => 'Test Product', + 'type' => 'VARIABLE', + 'attributes' => [ + [ + 'id' => $kind_attribute['attribute_id'], + 'name' => $kind_attribute['attribute_name'], + 'options' => [ 'normal', 'special', ], + 'variation' => true, + ], + [ + 'name' => 'logo', + 'options' => [ 'yes', 'no' ], + 'variation' => true, + ], + ], + 'defaultAttributes' => [ + [ + 'id' => $kind_attribute['attribute_id'], + 'attributeName' => $kind_attribute['attribute_name'], + 'attributeValue' => 'special', + ], + [ + 'attributeName' => 'logo', + 'attributeValue' => 'yes', + ] + ], + ], + ]; + $response = $this->graphql( compact( 'query', 'variables' ) ); + $expected = [ + $this->expectedObject( + 'createProduct.product', + [ + $this->expectedField( 'id', self::NOT_NULL ), + $this->expectedField( 'name', 'Test Product' ), + $this->expectedField( 'type', 'VARIABLE' ), + $this->expectedObject( + 'attributes', + [ + $this->expectedNode( + 'nodes', + [ + $this->expectedField( 'id', self::NOT_NULL ), + $this->expectedField( 'name', 'pa_kind' ), + $this->expectedField( 'label', 'Kind' ), + $this->expectedField( 'options', [ 'normal', 'special' ] ), + $this->expectedField( 'variation', true ), + $this->expectedField( 'scope', 'GLOBAL' ), + ], + ), + $this->expectedNode( + 'nodes', + [ + $this->expectedField( 'id', self::NOT_NULL ), + $this->expectedField( 'name', 'logo' ), + $this->expectedField( 'label', 'Logo' ), + $this->expectedField( 'options', [ 'yes', 'no' ] ), + $this->expectedField( 'variation', true ), + $this->expectedField( 'scope', 'LOCAL' ), + ], + ), + ] + ), + $this->expectedObject( + 'defaultAttributes', + [ + $this->expectedNode( + 'nodes', + [ + $this->expectedField( 'id', self::NOT_NULL ), + $this->expectedField( 'name', 'pa_kind' ), + $this->expectedField( 'label', 'Kind' ), + $this->expectedField( 'value', 'special' ), + ], + ), + $this->expectedNode( + 'nodes', + [ + $this->expectedField( 'id', self::NOT_NULL ), + $this->expectedField( 'name', 'logo' ), + $this->expectedField( 'label', 'Logo' ), + $this->expectedField( 'value', 'yes' ), + ], + ), + ] + ), + ] + ), + ]; + + $this->assertQuerySuccessful( $response, $expected ); + } + + public function testCreatingExternalProductWithCreateProduct() { + $query = ' + mutation ( $input: CreateProductInput! ) { + createProduct(input: $input) { + product { + id + name + type + ... on ExternalProduct { + externalUrl + buttonText + } + } + } + } + '; + + $this->loginAsShopManager(); + $variables = [ + 'input' => [ + 'name' => 'Test Product', + 'type' => 'EXTERNAL', + 'externalUrl' => 'https://example.com', + 'buttonText' => 'Buy Now', + ] + ]; + $response = $this->graphql( compact( 'query', 'variables' ) ); + $expected = [ + $this->expectedObject( + 'createProduct.product', + [ + $this->expectedField( 'id', self::NOT_NULL ), + $this->expectedField( 'name', 'Test Product' ), + $this->expectedField( 'type', 'EXTERNAL' ), + $this->expectedField( 'externalUrl', 'https://example.com' ), + $this->expectedField( 'buttonText', 'Buy Now' ), + ] + ), + ]; + + $this->assertQuerySuccessful( $response, $expected ); + } + + public function testCreatingGroupProductWithCreateProduct() { + $product_ids = [ + $this->factory->product->createSimple( [ 'name' => 'Product 1' ] ), + $this->factory->product->createSimple( [ 'name' => 'Product 2' ] ), + ]; + + $query = ' + mutation ( $input: CreateProductInput! ) { + createProduct(input: $input) { + product { + id + name + type + ... on GroupProduct { + products { + nodes { + id + name + } + } + } + } + } + } + '; + + $this->loginAsShopManager(); + $variables = [ + 'input' => [ + 'name' => 'Test Product', + 'type' => 'GROUPED', + 'groupedProducts' => $product_ids, + ] + ]; + $response = $this->graphql( compact( 'query', 'variables' ) ); + $expected = [ + $this->expectedObject( + 'createProduct.product', + [ + $this->expectedField( 'id', self::NOT_NULL ), + $this->expectedField( 'name', 'Test Product' ), + $this->expectedField( 'type', 'GROUPED' ), + $this->expectedField( 'products.nodes.#.name', 'Product 1' ), + $this->expectedField( 'products.nodes.#.name', 'Product 2' ), + ] + ), + ]; + + $this->assertQuerySuccessful( $response, $expected ); + } + + public function testUpdateProduct() { + $product_id = $this->factory->product->createSimple( + [ + 'name' => 'Test Product', + 'regular_price' => 10, + 'sale_price' => 7, + 'manage_stock' => true, + 'stock_status' => 'instock', + 'stock_quantity' => 3, + ] + ); + + $query = ' + mutation ( $input: UpdateProductInput! ) { + updateProduct(input: $input) { + product { + id + name + type + ... on ProductWithPricing { + price + regularPrice + salePrice + } + ... on InventoriedProduct { + stockQuantity + } + attributes { + nodes { + id + name + label + options + } + } + } + } + } + '; + + $variables = [ + 'input' => [ + 'id' => $product_id, + 'name' => 'Updated Product', + 'type' => 'SIMPLE', + 'regularPrice' => 19.99, + 'salePrice' => 14.99, + 'stockQuantity' => 5, + ] + ]; + // Assert mutation fails as unauthenticated user. + $response = $this->graphql( compact( 'query', 'variables' ) ); + $this->assertQueryError( $response ); + + + // Assert mutation fails as authenticated user without proper capabilities. + $this->loginAsCustomer(); + $response = $this->graphql( compact( 'query', 'variables' ) ); + $this->assertQueryError( $response ); + + // Assert mutation succeeds as authenticated user with proper capabilities. + $this->loginAsShopManager(); + $response = $this->graphql( compact( 'query', 'variables' ) ); + $expected = [ + $this->expectedObject( + 'updateProduct.product', + [ + $this->expectedField( 'id', self::NOT_NULL ), + $this->expectedField( 'name', 'Updated Product' ), + $this->expectedField( 'type', 'SIMPLE' ), + $this->expectedField( 'price', "$14.99" ), + $this->expectedField( 'regularPrice', "$19.99" ), + $this->expectedField( 'salePrice', "$14.99" ), + $this->expectedField( 'stockQuantity', 5 ), + ] + ), + ]; + + $this->assertQuerySuccessful( $response, $expected ); + } + + public function testDeleteProduct() { + $product_id = $this->factory->product->createSimple([ + 'name' => 'Test Product', + 'regular_price' => 10, + 'sale_price' => 7, + 'manage_stock' => true, + 'stock_status' => 'instock', + 'stock_quantity' => 3, + ]); + + $query = ' + mutation ( $input: DeleteProductInput! ) { + deleteProduct(input: $input) { + product { + id + name + type + ... on ProductWithPricing { + price + regularPrice + salePrice + } + ... on InventoriedProduct { + stockQuantity + } + attributes { + nodes { + id + name + label + options + } + } + } + } + } + '; + + $variables = [ + 'input' => [ + 'id' => $product_id, + 'force' => true, + ], + ]; + + // Assert mutation fails as unauthenticated user. + $response = $this->graphql( compact( 'query', 'variables' ) ); + $this->assertQueryError( $response ); + + + // Assert mutation fails as authenticated user without proper capabilities. + $this->loginAsCustomer(); + $response = $this->graphql( compact( 'query', 'variables' ) ); + $this->assertQueryError( $response ); + + // Assert mutation succeeds as authenticated user with proper capabilities. + $this->loginAsShopManager(); + $response = $this->graphql( compact( 'query', 'variables' ) ); + $expected = [ + $this->expectedObject( + 'deleteProduct.product', + [ + $this->expectedField( 'id', self::NOT_NULL ), + $this->expectedField( 'name', 'Test Product' ), + $this->expectedField( 'type', 'SIMPLE' ), + $this->expectedField( 'price', "$7.00" ), + $this->expectedField( 'regularPrice', "$10.00" ), + $this->expectedField( 'salePrice', "$7.00" ), + $this->expectedField( 'stockQuantity', 3 ), + ] + ), + ]; + $this->assertQuerySuccessful( $response, $expected ); + + // Assert product is deleted. + $product = wc_get_product( $product_id ); + $this->assertFalse( $product ); + } +} \ No newline at end of file diff --git a/tests/wpunit/ProductVariationMutationsTest.php b/tests/wpunit/ProductVariationMutationsTest.php new file mode 100644 index 000000000..e343446f7 --- /dev/null +++ b/tests/wpunit/ProductVariationMutationsTest.php @@ -0,0 +1,175 @@ +factory->product->createAttribute( 'kind', [ 'normal', 'special' ] ); + $product_id = $this->factory->product->createVariable( + [ + 'attributes' => [ + $this->factory->product->createAttributeObject( + $kind_attribute['attribute_id'], + $kind_attribute['attribute_taxonomy'], + $kind_attribute['term_ids'], + 0, + true, + true, + ), + ], + 'attribute_data' => [], + ] + ); + + $query = ' + mutation ($input: CreateProductVariationInput!) { + createProductVariation(input: $input) { + variation { + id + parent { node { id } } + price + regularPrice + salePrice + attributes { + nodes { + id + name + value + } + } + } + } + } + '; + + $variables = [ + 'input' => [ + 'productId' => $product_id, + 'regularPrice' => 14.99, + 'salePrice' => 9.99, + 'attributes' => [ + [ + 'id' => $kind_attribute['attribute_id'], + 'attributeName' => $kind_attribute['attribute_name'], + 'attributeValue' => 'special', + ], + ], + ], + ]; + $response = $this->graphql( compact( 'query', 'variables' ) ); + $expected = [ + $this->expectedObject( + 'createProductVariation.variation', + [ + $this->expectedField( 'id', self::NOT_NULL ), + $this->expectedField( 'parent.node.id', $this->toRelayId( 'product', $product_id ) ), + $this->expectedField( 'price', "$9.99" ), + $this->expectedField( 'regularPrice', "$14.99" ), + $this->expectedField( 'salePrice', "$9.99" ), + $this->expectedNode( + 'attributes.nodes', + [ + $this->expectedField( 'id', self::NOT_NULL ), + $this->expectedField( 'name', $kind_attribute['attribute_taxonomy'] ), + $this->expectedField( 'value', 'special' ), + ] + ), + ] + ) + ]; + + $this->assertQuerySuccessful( $response, $expected ); + } + + public function testUpdateProductVariation() { + $ids = $this->factory->product_variation->createSome( + $this->factory->product->createVariable() + ); + + $query = ' + mutation ($input: UpdateProductVariationInput!) { + updateProductVariation(input: $input) { + variation { + id + parent { node { id } } + price + regularPrice + salePrice + } + } + } + '; + + $variables = [ + 'input' => [ + 'productId' => $ids['product'], + 'id' => $ids['variations'][0], + 'regularPrice' => 19.99, + 'salePrice' => 14.99, + ], + ]; + $response = $this->graphql( compact( 'query', 'variables' ) ); + $expected = [ + $this->expectedObject( + 'updateProductVariation.variation', + [ + $this->expectedField( 'id', $this->toRelayId( 'product_variation', $ids['variations'][0] ) ), + $this->expectedField( 'parent.node.id', $this->toRelayId( 'product', $ids['product'] ) ), + $this->expectedField( 'price', "$14.99" ), + $this->expectedField( 'regularPrice', "$19.99" ), + $this->expectedField( 'salePrice', "$14.99" ), + ] + ) + ]; + + $this->assertQuerySuccessful( $response, $expected ); + } + + public function testDeleteProductVariation() { + $ids = $this->factory->product_variation->createSome( + $this->factory->product->createVariable() + ); + + $query = ' + mutation ($input: DeleteProductVariationInput!) { + deleteProductVariation(input: $input) { + variation { + id + parent { node { id } } + price + } + } + } + '; + + $variables = [ + 'input' => [ + 'id' => $ids['variations'][0], + 'force' => true, + ], + ]; + + $response = $this->graphql( compact( 'query', 'variables' ) ); + $this->assertQueryError( $response ); + + $this->loginAsCustomer(); + $response = $this->graphql( compact( 'query', 'variables' ) ); + $this->assertQueryError( $response ); + + $this->loginAsShopManager(); + $response = $this->graphql( compact( 'query', 'variables' ) ); + $expected = [ + $this->expectedObject( + 'deleteProductVariation.variation', + [ + $this->expectedField( 'id', $this->toRelayId( 'product_variation', $ids['variations'][0] ) ), + $this->expectedField( 'parent.node.id', $this->toRelayId( 'product', $ids['product'] ) ), + $this->expectedField( 'price', self::NOT_NULL ), + ] + ) + ]; + + $this->assertQuerySuccessful( $response, $expected ); + + // $variation = wc_get_product( $ids['variations'][0] ); + // $this->assertFalse( $variation ); + } +}