From 2a362659b8d6fbfdb5c281c7683a8967fe23213f Mon Sep 17 00:00:00 2001 From: Ruben Beglaryan Date: Tue, 5 Feb 2019 16:11:40 +0400 Subject: [PATCH] Add clone functionality for products and product-variants Add clone functionality for products and product-variants by PR #6. --- CHANGELOG.md | 8 + README.md | 3 +- composer.json | 3 +- src/Controller/AbstractController.php | 35 +++ src/Controller/ProductController.php | 215 ++++++++++++++++++ src/Controller/ProductModelController.php | 42 ++-- .../Compiler/LegacyServicePass.php | 27 +++ src/FlagbitProductClonerBundle.php | 7 + src/Resources/config/form_extensions/edit.yml | 55 +++-- src/Resources/config/routing.yml | 4 + src/Resources/config/services.yml | 20 ++ .../public/js/product/form/clone-modal.js | 85 ++++--- src/Resources/public/js/product/form/clone.js | 34 ++- .../templates/product/clone-button.html | 2 +- .../public/templates/product/clone-modal.html | 29 ++- src/Resources/translations/jsmessages.de.yml | 10 +- src/Resources/translations/jsmessages.en.yml | 10 +- 17 files changed, 473 insertions(+), 116 deletions(-) create mode 100644 src/Controller/AbstractController.php create mode 100644 src/Controller/ProductController.php create mode 100644 src/DependencyInjection/Compiler/LegacyServicePass.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 454d162..81c3f65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,3 +3,11 @@ ## Functionalities ## * Clone product model + + +# 1.1.0 # + +## Functionalities ## + +* Clone products and variation products + diff --git a/README.md b/README.md index 619bf33..a78be51 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ This bundle is aimed to offer product clone functionality within Akeneo PIM. ## Functionalities ## * Clone a product model +* Clone a product or a variant product ## Installation ## @@ -61,7 +62,7 @@ yarn run webpack ``` ## How to use it ## -Open a product from type **product model** and there open the **options dialog** at the **right corner**. +Open a product and there open the **options dialog** at the **right corner**. You can see it here on the screen: ![Product Model Clone Screen](https://raw.githubusercontent.com/Flagbit/akeneo-product-cloner/master/screens/product_model_clone.png "Product Model Clone Screen") diff --git a/composer.json b/composer.json index 9a7abe4..abd12e4 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,8 @@ } ], "require": { - "akeneo/pim-community-dev": "^2.0" + "akeneo/pim-community-dev": "^2.0", + "ext-json": "*" }, "require-dev": { "phpspec/phpspec": "*", diff --git a/src/Controller/AbstractController.php b/src/Controller/AbstractController.php new file mode 100644 index 0000000..8cd697b --- /dev/null +++ b/src/Controller/AbstractController.php @@ -0,0 +1,35 @@ +getNormalizer()->normalize($product, 'standard'); + + while ($parent = $product->getParent()) { + foreach ($parent->getValuesForVariation() as $value) { + //this workaround removes the attributes of all parent models, as the getValues() Method, + // which is called by the normalizer, returns all Values including the values of the parent Model + unset($normalizedProduct['values'][$value->getAttribute()->getCode()]); + } + $product = $parent; + }; + + foreach ($this->getAttributeRepository()->findUniqueAttributeCodes() as $attributeCode) { + unset($normalizedProduct['values'][$attributeCode]); + } + unset($normalizedProduct['identifier']); + return $normalizedProduct; + } +} diff --git a/src/Controller/ProductController.php b/src/Controller/ProductController.php new file mode 100644 index 0000000..37cd137 --- /dev/null +++ b/src/Controller/ProductController.php @@ -0,0 +1,215 @@ +productRepository = $productRepository; + $this->productUpdater = $productUpdater; + $this->productSaver = $productSaver; + $this->normalizer = $normalizer; + $this->validator = $validator; + $this->userContext = $userContext; + $this->productBuilder = $productBuilder; + $this->localizedConverter = $localizedConverter; + $this->emptyValuesFilter = $emptyValuesFilter; + $this->productValueConverter = $productValueConverter; + $this->constraintViolationNormalizer = $constraintViolationNormalizer; + $this->variantProductBuilder = $variantProductBuilder; + $this->attributeRepository = $attributeRepository; + } + /** + * @param Request $request + * + * @AclAncestor("pim_enrich_product_model_create") + * + * @return JsonResponse + */ + public function cloneAction(Request $request) : JsonResponse + { + $data = json_decode($request->getContent(), true); + + // check 'code_to_clone' is provided otherwise HTTP bad request + if (false === isset($data['code_to_clone'])) { + return new JsonResponse('Field "code_to_clone" is missing.', Response::HTTP_BAD_REQUEST); + } + + // check whether product to be cloned is found otherwise not found HTTP + $product = $this->productRepository->findOneByIdentifier($data['code_to_clone']); + if (null === $product) { + return new JsonResponse( + sprintf('Product model with code %s could not be found.', $data['code_to_clone']), + Response::HTTP_NOT_FOUND + ); + } + unset($data['code_to_clone']); + if (isset($data['parent'])) { + // TODO: remove this as soon as support of 2.1 is dropped + $cloneProduct = $this->variantProductBuilder->createProduct(); + } else { + $cloneProduct = $this->productBuilder->createProduct( + $data['code'] + ); + unset($data['code']); + } + + // clone product using Akeneo normalizer + $normalizedProduct = $this->normalizeProduct($product); + + $normalizedProduct = $this->removeIdentifierAttributeValue($normalizedProduct); + $this->productUpdater->update($cloneProduct, $normalizedProduct); + if (!empty($data['values'])) { + $this->updateProduct($cloneProduct, $data); + } + // validate product model clone and return violations if found + $violations = $this->validator->validate($cloneProduct); + if (count($violations) > 0) { + $normalizedViolations = []; + foreach ($violations as $violation) { + $violation = $this->constraintViolationNormalizer->normalize( + $violation, + 'internal_api', + ['product' => $cloneProduct] + ); + $normalizedViolations[] = $violation; + } + + return new JsonResponse(['values' => $normalizedViolations], Response::HTTP_BAD_REQUEST); + } + + $this->productSaver->save($cloneProduct); + + return new JsonResponse(); + } + + private function removeIdentifierAttributeValue(array $data): array + { + unset($data['identifier']); + $identifierAttributeCode = $this->attributeRepository->getIdentifier()->getCode(); + + if (isset($data['values'][$identifierAttributeCode])) { + unset($data['values'][$identifierAttributeCode]); + } + return $data; + } + /** + * Updates product with the provided request data + * + * @param ProductInterface $product + * @param array $data + */ + private function updateProduct(ProductInterface $product, array $data) + { + $values = $this->productValueConverter->convert($data['values']); + + $values = $this->localizedConverter->convertToDefaultFormats($values, [ + 'locale' => $this->userContext->getUiLocale()->getCode() + ]); + + $dataFiltered = $this->emptyValuesFilter->filter($product, ['values' => $values]); + + if (!empty($dataFiltered)) { + $data = array_replace($data, $dataFiltered); + } else { + $data['values'] = []; + } + + $this->productUpdater->update($product, $data); + } + + protected function getNormalizer(): NormalizerInterface + { + return $this->normalizer; + } + + protected function getAttributeRepository(): AttributeRepositoryInterface + { + return $this->attributeRepository; + } +} diff --git a/src/Controller/ProductModelController.php b/src/Controller/ProductModelController.php index abf0b3e..e514fb8 100644 --- a/src/Controller/ProductModelController.php +++ b/src/Controller/ProductModelController.php @@ -6,15 +6,15 @@ use Akeneo\Component\StorageUtils\Saver\SaverInterface; use Akeneo\Component\StorageUtils\Updater\ObjectUpdaterInterface; use Oro\Bundle\SecurityBundle\Annotation\AclAncestor; +use Pim\Component\Catalog\Repository\AttributeRepositoryInterface; use Pim\Component\Catalog\Repository\ProductModelRepositoryInterface; -use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Validator\Validator\ValidatorInterface; -class ProductModelController extends Controller +class ProductModelController extends AbstractController { /** * @var ProductModelRepositoryInterface @@ -50,20 +50,26 @@ class ProductModelController extends Controller * @var NormalizerInterface */ private $violationNormalizer; + /** + * @var AttributeRepositoryInterface + */ + private $attributeRepository; /** * DefaultController constructor. * * @param ProductModelRepositoryInterface $productModelRepository - * @param NormalizerInterface $normalizer - * @param SimpleFactoryInterface $productModelFactory - * @param ObjectUpdaterInterface $productModelUpdater - * @param SaverInterface $productModelSaver - * @param ValidatorInterface $validator - * @param NormalizerInterface $violiationNormalizer + * @param NormalizerInterface $normalizer + * @param SimpleFactoryInterface $productModelFactory + * @param ObjectUpdaterInterface $productModelUpdater + * @param SaverInterface $productModelSaver + * @param ValidatorInterface $validator + * @param NormalizerInterface $violiationNormalizer + * @param AttributeRepositoryInterface $attributeRepository */ public function __construct( ProductModelRepositoryInterface $productModelRepository, + AttributeRepositoryInterface $attributeRepository, NormalizerInterface $normalizer, SimpleFactoryInterface $productModelFactory, ObjectUpdaterInterface $productModelUpdater, @@ -78,6 +84,7 @@ public function __construct( $this->productModelSaver = $productModelSaver; $this->validator = $validator; $this->violationNormalizer = $violiationNormalizer; + $this->attributeRepository = $attributeRepository; } /** @@ -104,16 +111,15 @@ public function cloneAction(Request $request) : JsonResponse Response::HTTP_NOT_FOUND ); } - + unset($content['code_to_clone']); // create a new product model $cloneProductModel = $this->productModelFactory->create(); // clone product using Akeneo normalizer and updater for reusing code - $normalizedProduct = $this->normalizer->normalize($productModel, 'standard'); + $normalizedProduct = $this->normalizeProduct($productModel); $this->productModelUpdater->update($cloneProductModel, $normalizedProduct); - // set the new product model identifier 'code' - $cloneProductModel->setCode(isset($content['code']) ? $content['code'] : ''); - + $this->productModelUpdater->update($cloneProductModel, $content); + $cloneProductModel->setCode($content['code']); // validate product model clone and return violations if found $violations = $this->validator->validate($cloneProductModel); if (count($violations) > 0) { @@ -134,4 +140,14 @@ public function cloneAction(Request $request) : JsonResponse return new JsonResponse(); } + + protected function getNormalizer(): NormalizerInterface + { + return $this->normalizer; + } + + protected function getAttributeRepository(): AttributeRepositoryInterface + { + return $this->attributeRepository; + } } diff --git a/src/DependencyInjection/Compiler/LegacyServicePass.php b/src/DependencyInjection/Compiler/LegacyServicePass.php new file mode 100644 index 0000000..7649eb9 --- /dev/null +++ b/src/DependencyInjection/Compiler/LegacyServicePass.php @@ -0,0 +1,27 @@ +hasDefinition('pim_catalog.builder.variant_product')) { + $productControllerService = $container->getDefinition('flagbit_product_cloner.controller.product'); + + foreach ($productControllerService->getArguments() as $key => $argument) { + if ((string)$argument === 'pim_catalog.builder.variant_product') { + $productControllerService->setArgument($key, new Reference('pim_catalog.builder.product')); + } + } + } + } +} diff --git a/src/FlagbitProductClonerBundle.php b/src/FlagbitProductClonerBundle.php index 5f56abd..c57f753 100644 --- a/src/FlagbitProductClonerBundle.php +++ b/src/FlagbitProductClonerBundle.php @@ -2,8 +2,15 @@ namespace Flagbit\Bundle\ProductClonerBundle; +use Flagbit\Bundle\ProductClonerBundle\DependencyInjection\Compiler\LegacyServicePass; use Symfony\Component\HttpKernel\Bundle\Bundle; +use Symfony\Component\DependencyInjection\ContainerBuilder; class FlagbitProductClonerBundle extends Bundle { + public function build(ContainerBuilder $container) + { + parent::build($container); + $container->addCompilerPass(new LegacyServicePass()); + } } diff --git a/src/Resources/config/form_extensions/edit.yml b/src/Resources/config/form_extensions/edit.yml index 54ac18c..51e0386 100644 --- a/src/Resources/config/form_extensions/edit.yml +++ b/src/Resources/config/form_extensions/edit.yml @@ -6,26 +6,49 @@ extensions: aclResourceId: pim_enrich_product_model_remove position: 90 config: - formName: flagbit-product-model-edit-form-clone-modal + formName: flagbit-product-edit-form-clone-modal - flagbit-product-model-edit-form-clone-modal: + flagbit-product-edit-form-clone-button: + module: flagbit/product-edit-form/clone + parent: pim-product-edit-form-secondary-actions + targetZone: secondary-actions + aclResourceId: pim_enrich_product_model_remove + position: 90 + config: + formName: flagbit-product-edit-form-clone-modal + + flagbit-product-edit-form-clone-modal: module: flagbit/product-edit-form/clone-modal - parent: pim/form/common/creation/modal config: - labels: - title: flagbit.confirmation.clone.product_model - subTitle: pim_menu.item.product_model - content: confirmation.clone.product_model - picture: illustrations/Product-model.svg - successMessage: flagbit.entity.product_model.info.create_successful - postUrl: flagbit_product_cloner_product_model_clone - editRoute: pim_enrich_product_index + labels: + title: flagbit.confirmation.clone.product + subTitle: pim_menu.item.product + content: confirmation.clone.product + successMessage: flagbit.entity.product.info.create_successful + postProductRoute: flagbit_product_cloner_product_clone + postProductModelRoute: flagbit_product_cloner_product_model_clone + editProductRoute: pim_enrich_product_index + variantFormName: flagbit-product-edit-form-fields-container + productFormName: flagbit-product-edit-form-create-sku + excludedProperties: + - type + editRoute: pim_enrich_product_index + + flagbit-product-edit-form-fields-container: + module: pim/product-model-edit-form/add-child-form-fields-container + targetZone: fields-container + config: + fieldModules: + akeneo-simple-select-field: pim-product-model-add-child-field-simple-select + akeneo-metric-field: pim-product-model-add-child-field-metric + akeneo-switch-field: pim-product-model-add-child-field-switch + akeneo-simple-select-reference-data-field: pim-product-model-add-child-field-simple-select-reference-data + akeneo-text-field: pim-product-model-add-child-field-text + codeFieldModule: pim-product-model-add-child-field-code - flagbit-product-model-edit-form-create-sku: + flagbit-product-edit-form-create-sku: module: pim/form/common/creation/field - parent: flagbit-product-model-edit-form-clone-modal - targetZone: fields - position: 10 + targetZone: fields-container config: identifier: code - label: pim_enrich.entity.create_popin.code + label: pim_enrich.entity.create_popin.code \ No newline at end of file diff --git a/src/Resources/config/routing.yml b/src/Resources/config/routing.yml index 1e5f946..ef6d189 100644 --- a/src/Resources/config/routing.yml +++ b/src/Resources/config/routing.yml @@ -2,3 +2,7 @@ flagbit_product_cloner_product_model_clone: path: /flagbit/product-model/clone defaults: { _controller: flagbit_product_cloner.controller.product_model:cloneAction } methods: [POST] +flagbit_product_cloner_product_clone: + path: /flagbit/product/clone + defaults: { _controller: flagbit_product_cloner.controller.product:cloneAction } + methods: [POST] diff --git a/src/Resources/config/services.yml b/src/Resources/config/services.yml index bd14f60..a00a09e 100644 --- a/src/Resources/config/services.yml +++ b/src/Resources/config/services.yml @@ -3,9 +3,29 @@ services: class: Flagbit\Bundle\ProductClonerBundle\Controller\ProductModelController arguments: - '@pim_catalog.repository.product_model' + - '@pim_catalog.repository.attribute' - '@pim_serializer' - '@pim_catalog.factory.product_model' - '@pim_catalog.updater.product_model' - '@pim_catalog.saver.product_model' - '@pim_catalog.validator.product' - '@pim_enrich.normalizer.violation' + flagbit_product_cloner.controller.product: + class: Flagbit\Bundle\ProductClonerBundle\Controller\ProductController + arguments: + - '@pim_catalog.repository.product' + - '@pim_catalog.repository.attribute' + - '@pim_catalog.updater.product' + - '@pim_catalog.saver.product' + - '@pim_serializer' + - '@pim_catalog.validator.product' + - '@pim_user.context.user' + - '@pim_catalog.builder.product' + - '@pim_catalog.localization.localizer.converter' + - '@pim_catalog.comparator.filter.product' + - '@pim_enrich.converter.enrich_to_standard.product_value' + - '@pim_enrich.normalizer.product_violation' + - '@pim_catalog.builder.variant_product' +#the argument @pim_catalog.builder.variant_product is changed to @pim_catalog.builder.product +#via compiler pass as the service is removed since 2.2 +#TODO: remove this manipulation when the support for 2.1 is dropped diff --git a/src/Resources/public/js/product/form/clone-modal.js b/src/Resources/public/js/product/form/clone-modal.js index 9c3f2b8..2d4b229 100644 --- a/src/Resources/public/js/product/form/clone-modal.js +++ b/src/Resources/public/js/product/form/clone-modal.js @@ -13,7 +13,7 @@ define( 'oro/loading-mask', 'pim/router', 'oro/messenger', - 'pim/template/form/creation/modal' + 'flagbit/template/product/clone-modal' ], function ( $, @@ -32,31 +32,44 @@ define( return BaseForm.extend({ config: {}, template: _.template(template), - + globalErrors: [], /** * {@inheritdoc} */ initialize(meta) { this.config = meta.config; - + this.globalErrors = []; BaseForm.prototype.initialize.apply(this, arguments); }, + getFieldsFormName() { + if (this.getRoot().model.has('parent')) { + return this.config.variantFormName; + } + else { + return this.config.productFormName; + } + }, - /** - * {@inheritdoc} - */ render() { this.$el.html(this.template({ - titleLabel: __(this.config.labels.title), - subTitleLabel: __(this.config.labels.subTitle), - contentLabel: __(this.config.labels.content), + modalTitle: __(this.config.labels.title), + subTitle: __(this.config.labels.subTitle), + content: __(this.config.labels.content), picture: this.config.picture, - fields: null + errors: this.globalErrors })); - this.renderExtensions(); - - return this; + return FormBuilder.build(this.getFieldsFormName()).then(form => { + this.addExtension( + form.code, + form, + 'fields-container', + 10000 + ); + form.configure(); + this.renderExtensions(); + return this; + }); }, /** @@ -67,10 +80,9 @@ define( * @return {Promise} */ open() { - const deferred = $.Deferred(); + const deferred = $.Deferred(); const modal = new Backbone.BootstrapModal({ - title: __(this.config.labels.title), content: '', cancelText: __('pim_enrich.entity.create_popin.labels.cancel'), okText: __('pim_enrich.entity.create_popin.labels.save'), @@ -83,9 +95,8 @@ define( const modalBody = modal.$('.modal-body'); modalBody.addClass('creation'); - this.render() - .setElement(modalBody) - .render(); + this.setElement(modalBody); + this.render(); modal.on('cancel', () => { deferred.reject(); @@ -113,31 +124,12 @@ define( }); }, - /** - * Normalize the path property for validation errors - * @param {Array} errors - * @return {Array} - */ - normalize(errors) { - const values = errors.values || []; - - return values.map(error => { - if (!error.path) { - error.path = error.attribute; - } - - return error; - }) - }, - /** * Save the form content by posting it to backend * * @return {Promise} */ save() { - this.validationErrors = {}; - const loadingMask = new LoadingMask(); this.$el.empty().append(loadingMask.render().$el.show()); @@ -149,22 +141,23 @@ define( } return $.ajax({ - url: Routing.generate(this.config.postUrl), + url: Routing.generate(this.getPostRoute()), type: 'POST', data: JSON.stringify(data) }).fail(function (response) { if (response.responseJSON) { - this.getRoot().trigger( - 'pim_enrich:form:entity:bad_request', - {'sentData': this.getFormData(), 'response': response.responseJSON.values} - ); + this.globalErrors = response.responseJSON.values; + this.render(); } - - this.validationErrors = response.responseJSON ? - this.normalize(response.responseJSON) : [{message: __('error.common')}]; - this.render(); }.bind(this)) .always(() => loadingMask.remove()); + }, + getPostRoute() { + if (this.getFormData().type === 'model') { + return this.config.postProductModelRoute; + } else { + return this.config.postProductRoute; + } } }); } diff --git a/src/Resources/public/js/product/form/clone.js b/src/Resources/public/js/product/form/clone.js index b9fa926..9619123 100644 --- a/src/Resources/public/js/product/form/clone.js +++ b/src/Resources/public/js/product/form/clone.js @@ -4,40 +4,51 @@ * Clone product extension */ define([ + 'jquery', 'pim/form', 'underscore', 'oro/translator', 'backbone', 'pim/form-builder', 'flagbit/template/product/clone-button', - 'flagbit/template/product/clone-modal', ], function ( + $, BaseForm, _, __, Backbone, FormBuilder, - template, - templateModal + template ) { return BaseForm.extend({ template: _.template(template), - templateModal: _.template(templateModal), - - events: { - 'click .clone-product-model-button': 'openModal' - }, initialize(config) { this.config = config.config; - BaseForm.prototype.initialize.apply(this, arguments); }, openModal() { return FormBuilder.build(this.config.formName).then(modal => { - modal.setData('code_to_clone', this.getRoot().model.get('code')); + + const rootModel = this.getRoot().model; + var productType, codeToClone; + if (rootModel.has('identifier')) { + productType = 'product'; + codeToClone = rootModel.get('identifier') + } else { + productType = 'model'; + codeToClone = rootModel.get('code') + } + + const initialModalState = { + parent: rootModel.get('parent'), + values: {}, + code_to_clone: codeToClone, + type: productType + }; + modal.setData(initialModalState); modal.open(); }); }, @@ -59,6 +70,9 @@ define([ this.$el.html(this.template()); + $('.clone-product-button').on('click', () => { + this.openModal(); + }); return this; } }); diff --git a/src/Resources/public/templates/product/clone-button.html b/src/Resources/public/templates/product/clone-button.html index 966008d..3ea0093 100644 --- a/src/Resources/public/templates/product/clone-button.html +++ b/src/Resources/public/templates/product/clone-button.html @@ -1 +1 @@ - + diff --git a/src/Resources/public/templates/product/clone-modal.html b/src/Resources/public/templates/product/clone-modal.html index 59f62ea..8303532 100644 --- a/src/Resources/public/templates/product/clone-modal.html +++ b/src/Resources/public/templates/product/clone-modal.html @@ -1,18 +1,23 @@
-
-
- +
+
+
-
-
-
-
<%- subTitle %>
-
<%- modalTitle %>
- -
-
-
+
+
+
<%- subTitle %>
+
<%- modalTitle %>
+
+
+ <% if (errors.length > 0) { %> +
+
    + <% errors.forEach(function(error) { %> +
  • <%- error.message %>
  • + <% }) %> +
+ <% } %>
diff --git a/src/Resources/translations/jsmessages.de.yml b/src/Resources/translations/jsmessages.de.yml index 7b6ff13..2164063 100644 --- a/src/Resources/translations/jsmessages.de.yml +++ b/src/Resources/translations/jsmessages.de.yml @@ -3,14 +3,8 @@ flagbit: product: btn: clone: Klonen - product_model: info: - create_successful: Produktmodell erfolgreich geklont + create_successful: Produkt erfolgreich geklont confirmation: clone: - product_model: Klone Produktmodell - -# Grid action messages -confirmation: - clone: - product_model: Alle Produktmodellinformationen Daten werden in ein neues Produktmodell geklont. + product: Klone Produkt \ No newline at end of file diff --git a/src/Resources/translations/jsmessages.en.yml b/src/Resources/translations/jsmessages.en.yml index 37adc89..e4e8497 100644 --- a/src/Resources/translations/jsmessages.en.yml +++ b/src/Resources/translations/jsmessages.en.yml @@ -3,14 +3,8 @@ flagbit: product: btn: clone: Clone - product_model: info: - create_successful: Product model successfully cloned + create_successful: Product successfully cloned confirmation: clone: - product_model: Clone product model - -# Grid action messages -confirmation: - clone: - product_model: All product model information will be cloned into a new product model. + product: Clone product \ No newline at end of file