diff --git a/README.md b/README.md
index 0680a497..3b6243ce 100644
--- a/README.md
+++ b/README.md
@@ -99,12 +99,14 @@ fos_sylius_import_export:
* payment_method (csv, excel, json)
* tax_category (csv, excel, json)
* customer (json)
+* product (csv)
### Available exporter types
* country (csv, excel, json)
* order (csv, excel, json)
* customer (csv, excel, json)
+* product (csv)
## Example import files
diff --git a/features/export/cli/exporting_products_to_csv_via_cli.feature b/features/export/cli/exporting_products_to_csv_via_cli.feature
new file mode 100644
index 00000000..5c167fdf
--- /dev/null
+++ b/features/export/cli/exporting_products_to_csv_via_cli.feature
@@ -0,0 +1,13 @@
+@managing_products
+Feature: exporting products to csv-file
+ In order to have my products exported to an external target
+ As a developer
+ I want to be able to export product data to csv file from the commandline
+
+ Background:
+ Given I have a working command-line interface
+
+ @cli_importer_exporter
+ Scenario: Exporting products to csv-file
+ When I export "product" data as "csv" to the file "products_export.csv" with the cli-command
+ Then I should see "Exported" in the output
diff --git a/features/export/ui/exporting_products.feature b/features/export/ui/exporting_products.feature
new file mode 100644
index 00000000..3d3c23dd
--- /dev/null
+++ b/features/export/ui/exporting_products.feature
@@ -0,0 +1,17 @@
+@managing_products
+Feature: Export Products from grid
+ In order to have my products exported to an external target
+ As an Administrator
+ I want to be able to export product data to csv file from backOffice
+
+ Background:
+ Given I am logged in as an administrator
+ And the store has a product "T-shirt cool"
+
+ @ui
+ Scenario: Exporting products should export all of them
+ When I open the product admin index page
+ And I should see 1 products in the list
+ Then I go to "/admin/export/sylius.product/csv" homepage
+ And response should contain "Code,Locale,Name,Description,Short_description,Meta_description,Meta_keywords,Main_taxon,Taxons,Channels,Enabled"
+ And response should contain 'T_SHIRT_COOL,en_US,"T-shirt cool",,,,,,,,1'
diff --git a/features/export/ui/exporting_products_with_attributs.feature b/features/export/ui/exporting_products_with_attributs.feature
new file mode 100644
index 00000000..8c8de5ef
--- /dev/null
+++ b/features/export/ui/exporting_products_with_attributs.feature
@@ -0,0 +1,21 @@
+@managing_products
+Feature: Export Products with attributes from grid
+ In order to have my products exported to an external target
+ As an Administrator
+ I want to be able to export product data and her attributes to csv file from backOffice
+
+ Background:
+ Given I am logged in as an administrator
+ And the store has a select product attribute "Attribute select" with values "select1" and "select2"
+ And the store has a product "T-shirt cool"
+ And this product has text attribute "Attribute text" with value "Banana"
+ And this product has textarea attribute "Attribute textarea" with value "Banana
Bananaaaa !!!"
+ And this product has percent attribute "Attribute percent" with value 22%
+
+ @ui
+ Scenario: Exporting products should export all of them
+ When I open the product admin index page
+ And I should see 1 products in the list
+ Then I go to "/admin/export/sylius.product/csv" homepage
+ And response should contain "Attribute_text,Attribute_textarea,Attribute_percent"
+ And response should contain 'Banana,"Banana
Bananaaaa !!!",0.22'
diff --git a/features/import/cli/importing_products_from_csv_via_cli.feature b/features/import/cli/importing_products_from_csv_via_cli.feature
new file mode 100644
index 00000000..33388e4d
--- /dev/null
+++ b/features/import/cli/importing_products_from_csv_via_cli.feature
@@ -0,0 +1,16 @@
+@managing_products
+Feature: Importing products from csv with the command-line interface
+ In order to have my products from external source
+ As a developer
+ I want to be able to import data from csv file from the commandline
+
+ Background:
+ Given I have a working command-line interface
+
+ @cli_importer_exporter
+ Scenario: Importing defined products with the cli-command
+ When I import "product" data from csv file "products.csv" file with the cli-command
+ Then I should see "Imported" in the output
+ And I should have at least the following product ids in the database:
+ | 123456 |
+ | 222333 |
diff --git a/features/import/ui/importing_products.feature b/features/import/ui/importing_products.feature
new file mode 100644
index 00000000..775a632f
--- /dev/null
+++ b/features/import/ui/importing_products.feature
@@ -0,0 +1,17 @@
+@managing_products
+Feature: Import Products from grid
+ In order to have my products exported to an external target
+ As an Administrator
+ I want to be able to import product data to csv file from backOffice
+
+ Background:
+ Given I am logged in as an administrator
+
+ @ui
+ Scenario: Import products should create all of them
+ When I open the product admin index page
+ And I import product data from "products.csv" csv file
+ Then I should see a notification that the import was successful
+ And I should see 2 products in the list
+ And the first product on the list should have name "Product 1"
+ And the last product on the list should have name "Product 2"
diff --git a/features/import/ui/importing_products_update.feature b/features/import/ui/importing_products_update.feature
new file mode 100644
index 00000000..db3a4d65
--- /dev/null
+++ b/features/import/ui/importing_products_update.feature
@@ -0,0 +1,19 @@
+@managing_products
+Feature: Import Products from grid
+ In order to have my products exported to an external target
+ As an Administrator
+ I want to be able to import product data to csv file from backOffice
+
+ Background:
+ Given I am logged in as an administrator
+
+ @ui
+ Scenario: Import products should update them
+ When I open the product admin index page
+ And I import product data from "products.csv" csv file
+ Then I should see a notification that the import was successful
+ And I import product data from "products_update.csv" csv file
+ Then I should see a notification that the import was successful
+ And I should see 2 products in the list
+ And the first product on the list should have name "Product 1"
+ And the last product on the list should have name "Product 2 update"
diff --git a/features/import/ui/importing_products_with_attributes.feature b/features/import/ui/importing_products_with_attributes.feature
new file mode 100644
index 00000000..6b4a4dc2
--- /dev/null
+++ b/features/import/ui/importing_products_with_attributes.feature
@@ -0,0 +1,21 @@
+@managing_products
+Feature: Import Products with attributes from grid
+ In order to have my products exported to an external target
+ As an Administrator
+ I want to be able to import product data and her attributes to csv file from backOffice
+
+ Background:
+ Given I am logged in as an administrator
+ Given the store has locale "en_US"
+ And the store has a text product attribute "Attribute text"
+ And the store has a textarea product attribute "Attribute textarea"
+ And the store has a percent product attribute "Attribute percent"
+
+ @ui
+ Scenario: Exporting products should export all of them
+ When I open the product admin index page
+ And I import product data from "products_attr.csv" csv file
+ Then I should see a notification that the import was successful
+ And I should see 2 products in the list
+ Then the product "Product 1" should appear in the registry
+ And attribute "Attribute text" of product "Product 1" should be "Banana" in "en_US"
diff --git a/spec/Exporter/Transformer/Handler/ArrayToStringHandlerSpec.php b/spec/Exporter/Transformer/Handler/ArrayToStringHandlerSpec.php
new file mode 100644
index 00000000..e02d2f4a
--- /dev/null
+++ b/spec/Exporter/Transformer/Handler/ArrayToStringHandlerSpec.php
@@ -0,0 +1,53 @@
+shouldHaveType(ArrayToStringHandler::class);
+ }
+
+ function it_extends()
+ {
+ $this->shouldHaveType(Handler::class);
+ }
+
+ function it_should_implement()
+ {
+ $this->shouldImplement(HandlerInterface::class);
+ }
+
+ function it_should_process_directly()
+ {
+ $array = ['a', 'b', 'c'];
+ $this->handle('test', $array)->shouldBeString();
+ $this->handle('test', $array)->shouldBe('a|b|c');
+ }
+
+ function it_should_process_via_pool()
+ {
+ $array = ['a', 'b', 'c'];
+
+ $generator = new RewindableGenerator(function () {
+ return [$this->getWrappedObject()];
+ }, $count = 1);
+
+ $pool = new Pool($generator);
+
+ $result = $pool->handle('test', $array);
+
+ Assert::same('a|b|c', $result);
+ }
+}
diff --git a/spec/Importer/ImporterResultSpec.php b/spec/Importer/ImporterResultSpec.php
index d77ce520..eff968e6 100644
--- a/spec/Importer/ImporterResultSpec.php
+++ b/spec/Importer/ImporterResultSpec.php
@@ -6,14 +6,15 @@
use FriendsOfSylius\SyliusImportExportPlugin\Importer\ImporterResult;
use PhpSpec\ObjectBehavior;
+use Psr\Log\LoggerInterface;
use Symfony\Component\Stopwatch\Stopwatch;
use Symfony\Component\Stopwatch\StopwatchEvent;
class ImporterResultSpec extends ObjectBehavior
{
- function let(Stopwatch $stopwatch)
+ function let(Stopwatch $stopwatch, LoggerInterface $logger)
{
- $this->beConstructedWith($stopwatch);
+ $this->beConstructedWith($stopwatch, $logger);
}
function it_is_initializable()
diff --git a/spec/Importer/JsonResourceImporterSpec.php b/spec/Importer/JsonResourceImporterSpec.php
index 14a85d31..5d826119 100644
--- a/spec/Importer/JsonResourceImporterSpec.php
+++ b/spec/Importer/JsonResourceImporterSpec.php
@@ -6,6 +6,7 @@
use Doctrine\Common\Persistence\ObjectManager;
use FriendsOfSylius\SyliusImportExportPlugin\Importer\ImporterResultInterface;
+use FriendsOfSylius\SyliusImportExportPlugin\Importer\ImportResultLoggerInterface;
use FriendsOfSylius\SyliusImportExportPlugin\Importer\JsonResourceImporter;
use FriendsOfSylius\SyliusImportExportPlugin\Importer\ResourceImporter;
use FriendsOfSylius\SyliusImportExportPlugin\Processor\ResourceProcessorInterface;
@@ -17,7 +18,7 @@ class JsonResourceImporterSpec extends ObjectBehavior
function let(
ObjectManager $objectManager,
ResourceProcessorInterface $resourceProcessor,
- ImporterResultInterface $importerResult
+ ImportResultLoggerInterface $importerResult
) {
$this->beConstructedWith($objectManager, $resourceProcessor, $importerResult, 0, false, false);
}
diff --git a/spec/Importer/ResourceImporterSpec.php b/spec/Importer/ResourceImporterSpec.php
index 1e5d8675..0ebc28f4 100644
--- a/spec/Importer/ResourceImporterSpec.php
+++ b/spec/Importer/ResourceImporterSpec.php
@@ -7,6 +7,7 @@
use Doctrine\Common\Persistence\ObjectManager;
use FriendsOfSylius\SyliusImportExportPlugin\Importer\ImporterInterface;
use FriendsOfSylius\SyliusImportExportPlugin\Importer\ImporterResultInterface;
+use FriendsOfSylius\SyliusImportExportPlugin\Importer\ImportResultLoggerInterface;
use FriendsOfSylius\SyliusImportExportPlugin\Importer\ResourceImporter;
use FriendsOfSylius\SyliusImportExportPlugin\Processor\ResourceProcessorInterface;
use PhpSpec\ObjectBehavior;
@@ -21,7 +22,7 @@ function let(
ReaderFactory $readerFactory,
ObjectManager $objectManager,
ResourceProcessorInterface $resourceProcessor,
- ImporterResultInterface $importerResult
+ ImportResultLoggerInterface $importerResult
) {
$this->beConstructedWith($readerFactory, $objectManager, $resourceProcessor, $importerResult, false, false, false);
}
diff --git a/src/Controller/ImportDataController.php b/src/Controller/ImportDataController.php
index ca430090..f5922ded 100644
--- a/src/Controller/ImportDataController.php
+++ b/src/Controller/ImportDataController.php
@@ -8,6 +8,7 @@
use FriendsOfSylius\SyliusImportExportPlugin\Form\ImportType;
use FriendsOfSylius\SyliusImportExportPlugin\Importer\ImporterInterface;
use FriendsOfSylius\SyliusImportExportPlugin\Importer\ImporterRegistry;
+use FriendsOfSylius\SyliusImportExportPlugin\Importer\ImporterResult;
use Sylius\Component\Registry\ServiceRegistryInterface;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;
@@ -84,14 +85,22 @@ private function importData(string $importer, FormInterface $form): void
$this->flashBag->add('error', $message);
}
- /** @var UploadedFile $file */
+ /** @var UploadedFile|null $file */
$file = $form->get('import-data')->getData();
/** @var ImporterInterface $service */
$service = $this->registry->get($name);
+
+ if (null === $file) {
+ throw new ImporterException('No file selected');
+ }
+
$path = $file->getRealPath();
+
if (false === $path) {
throw new ImporterException(sprintf('File %s could not be loaded', $file->getClientOriginalName()));
}
+
+ /** @var ImporterResult $result */
$result = $service->import($path);
$message = sprintf(
@@ -104,5 +113,9 @@ private function importData(string $importer, FormInterface $form): void
);
$this->flashBag->add('success', $message);
+
+ if ($result->getMessage() !== null) {
+ $this->flashBag->add('error', $result->getMessage());
+ }
}
}
diff --git a/src/Exporter/Plugin/PluginPool.php b/src/Exporter/Plugin/PluginPool.php
index e6447b40..e5cb5f3f 100644
--- a/src/Exporter/Plugin/PluginPool.php
+++ b/src/Exporter/Plugin/PluginPool.php
@@ -6,17 +6,17 @@
class PluginPool implements PluginPoolInterface
{
- /** @var PluginInterface[] */
- private $plugins;
-
/** @var array */
- private $exportKeys;
+ protected $exportKeys;
/** @var array */
- private $exportKeysNotFound;
+ protected $exportKeysAvailable = [];
+
+ /** @var PluginInterface[] */
+ private $plugins;
/** @var array */
- private $exportKeysAvailable = [];
+ private $exportKeysNotFound;
/**
* @param PluginInterface[] $plugins
diff --git a/src/Exporter/Plugin/ProductPluginPool.php b/src/Exporter/Plugin/ProductPluginPool.php
new file mode 100644
index 00000000..b67212bc
--- /dev/null
+++ b/src/Exporter/Plugin/ProductPluginPool.php
@@ -0,0 +1,35 @@
+attributeCodesProvider = $attributeCodesProvider;
+ $this->imageTypesProvider = $imageTypesProvider;
+ }
+
+ public function initPlugins(array $ids): void
+ {
+ $this->exportKeys = \array_merge($this->exportKeys, $this->attributeCodesProvider->getAttributeCodesList());
+ $this->exportKeys = \array_merge($this->exportKeys, $this->imageTypesProvider->getProductImagesCodesWithPrefixList());
+ $this->exportKeysAvailable = $this->exportKeys;
+ parent::initPlugins($ids);
+ }
+}
diff --git a/src/Exporter/Plugin/ProductResourcePlugin.php b/src/Exporter/Plugin/ProductResourcePlugin.php
new file mode 100644
index 00000000..1f23ecc9
--- /dev/null
+++ b/src/Exporter/Plugin/ProductResourcePlugin.php
@@ -0,0 +1,141 @@
+channelPricingRepository = $channelPricingRepository;
+ $this->productVariantRepository = $productVariantRepository;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function init(array $idsToExport): void
+ {
+ parent::init($idsToExport);
+
+ /** @var ProductInterface $resource */
+ foreach ($this->resources as $resource) {
+ $this->addTranslationData($resource);
+ $this->addTaxonData($resource);
+ $this->addAttributeData($resource);
+ $this->addChannelData($resource);
+ $this->addImageData($resource);
+ $this->addPriceData($resource);
+ }
+ }
+
+ private function addTranslationData(ProductInterface $resource): void
+ {
+ $translation = $resource->getTranslation();
+
+ $this->addDataForResource($resource, 'Locale', $translation->getLocale());
+ $this->addDataForResource($resource, 'Name', $translation->getName());
+ $this->addDataForResource($resource, 'Description', $translation->getDescription());
+ $this->addDataForResource($resource, 'Short_description', $translation->getShortDescription());
+ $this->addDataForResource($resource, 'Meta_description', $translation->getMetaDescription());
+ $this->addDataForResource($resource, 'Meta_keywords', $translation->getMetaKeywords());
+ }
+
+ private function addTaxonData(ProductInterface $resource): void
+ {
+ $mainTaxonSlug = '';
+
+ /** @var \Sylius\Component\Core\Model\TaxonInterface $taxon */
+ $mainTaxon = $resource->getMainTaxon();
+ if (null !== $mainTaxon) {
+ $mainTaxonSlug = $mainTaxon->getCode();
+ }
+
+ $this->addDataForResource($resource, 'Main_taxon', $mainTaxonSlug);
+
+ $taxonsSlug = '';
+ $taxons = $resource->getTaxons();
+ foreach ($taxons as $taxon) {
+ $taxonsSlug .= $taxon->getCode() . '|';
+ }
+
+ $taxonsSlug = \rtrim($taxonsSlug, '|');
+ $this->addDataForResource($resource, 'Taxons', $taxonsSlug);
+ }
+
+ private function addChannelData(ProductInterface $resource): void
+ {
+ $channelSlug = '';
+
+ /** @var \Sylius\Component\Core\Model\ChannelInterface[] $channel */
+ $channels = $resource->getChannels();
+ foreach ($channels as $channel) {
+ $channelSlug .= $channel->getCode() . '|';
+ }
+
+ $channelSlug = \rtrim($channelSlug, '|');
+
+ $this->addDataForResource($resource, 'Channels', $channelSlug);
+ }
+
+ private function addAttributeData(ProductInterface $resource): void
+ {
+ $attributes = $resource->getAttributes();
+
+ /** @var AttributeValueInterface $attribute */
+ foreach ($attributes as $attribute) {
+ $this->addDataForResource($resource, $attribute->getCode(), $attribute->getValue());
+ }
+ }
+
+ private function addImageData(ProductInterface $resource): void
+ {
+ $images = $resource->getImages();
+
+ /** @var ImageInterface $image */
+ foreach ($images as $image) {
+ $this->addDataForResource($resource, ImageTypesProvider::IMAGES_PREFIX . $image->getType(), $image->getPath());
+ }
+ }
+
+ private function addPriceData(ProductInterface $resource): void
+ {
+ /** @var ProductVariantInterface|null $productVariant */
+ $productVariant = $this->productVariantRepository->findOneBy(['code' => $resource->getCode()]);
+ if ($productVariant === null) {
+ return;
+ }
+
+ /** @var \Sylius\Component\Core\Model\ChannelInterface[] $channel */
+ $channels = $resource->getChannels();
+ foreach ($channels as $channel) {
+ $channelPricing = $this->channelPricingRepository->findOneBy([
+ 'channelCode' => $channel->getCode(),
+ 'productVariant' => $productVariant,
+ ]);
+
+ $this->addDataForResource($resource, 'Price', $channelPricing->getPrice());
+ }
+ }
+}
diff --git a/src/Exporter/ProductResourceExporter.php b/src/Exporter/ProductResourceExporter.php
new file mode 100644
index 00000000..a795ad76
--- /dev/null
+++ b/src/Exporter/ProductResourceExporter.php
@@ -0,0 +1,39 @@
+attributeCodesProvider = $attributeCodesProvider;
+ $this->imageTypesProvider = $imageTypesProvider;
+ }
+
+ public function export(array $idsToExport): void
+ {
+ $this->resourceKeys = \array_merge($this->resourceKeys, $this->attributeCodesProvider->getAttributeCodesList());
+ $this->resourceKeys = \array_merge($this->resourceKeys, $this->imageTypesProvider->getProductImagesCodesWithPrefixList());
+ parent::export($idsToExport);
+ }
+}
diff --git a/src/Exporter/Transformer/Handler/ArrayToStringHandler.php b/src/Exporter/Transformer/Handler/ArrayToStringHandler.php
new file mode 100644
index 00000000..4a6fecd3
--- /dev/null
+++ b/src/Exporter/Transformer/Handler/ArrayToStringHandler.php
@@ -0,0 +1,23 @@
+stopwatch = $stopwatch;
+ $this->logger = $logger;
}
public function start(): void
@@ -85,4 +95,19 @@ public function getDuration(): float
{
return $this->stopWatchEvent->getDuration();
}
+
+ public function setMessage(string $message): void
+ {
+ $this->message = $message;
+ }
+
+ public function getMessage(): ?string
+ {
+ return $this->message;
+ }
+
+ public function getLogger(): LoggerInterface
+ {
+ return $this->logger;
+ }
}
diff --git a/src/Importer/JsonResourceImporter.php b/src/Importer/JsonResourceImporter.php
index 317654e4..33fd5449 100644
--- a/src/Importer/JsonResourceImporter.php
+++ b/src/Importer/JsonResourceImporter.php
@@ -13,7 +13,7 @@ final class JsonResourceImporter extends ResourceImporter implements SingleDataA
public function __construct(
ObjectManager $objectManager,
ResourceProcessorInterface $resourceProcessor,
- ImporterResultInterface $importerResult,
+ ImportResultLoggerInterface $importerResult,
int $batchSize,
bool $failOnIncomplete,
bool $stopOnFailure
diff --git a/src/Importer/ResourceImporter.php b/src/Importer/ResourceImporter.php
index 18910701..90e7818d 100644
--- a/src/Importer/ResourceImporter.php
+++ b/src/Importer/ResourceImporter.php
@@ -21,7 +21,7 @@ class ResourceImporter implements ImporterInterface
/** @var ResourceProcessorInterface */
protected $resourceProcessor;
- /** @var ImporterResultInterface */
+ /** @var ImportResultLoggerInterface */
protected $result;
/** @var int */
@@ -40,7 +40,7 @@ public function __construct(
ReaderFactory $readerFactory,
ObjectManager $objectManager,
ResourceProcessorInterface $resourceProcessor,
- ImporterResultInterface $importerResult,
+ ImportResultLoggerInterface $importerResult,
int $batchSize,
bool $failOnIncomplete,
bool $stopOnFailure
@@ -87,6 +87,8 @@ public function importData(int $i, array $row): bool
$this->batchCount = 0;
}
} catch (ItemIncompleteException $e) {
+ $this->result->setMessage($e->getMessage());
+ $this->result->getLogger()->critical($e->getMessage());
if ($this->failOnIncomplete) {
$this->result->failed($i);
if ($this->stopOnFailure) {
@@ -97,6 +99,8 @@ public function importData(int $i, array $row): bool
}
} catch (ImporterException $e) {
$this->result->failed($i);
+ $this->result->setMessage($e->getMessage());
+ $this->result->getLogger()->critical($e->getMessage());
if ($this->stopOnFailure) {
return true;
}
diff --git a/src/Importer/Transformer/Handler.php b/src/Importer/Transformer/Handler.php
new file mode 100644
index 00000000..9ef95732
--- /dev/null
+++ b/src/Importer/Transformer/Handler.php
@@ -0,0 +1,55 @@
+successor === null) {
+ $this->successor = $handler;
+
+ return;
+ }
+
+ $this->successor->setSuccessor($handler);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ final public function handle($type, $value)
+ {
+ $response = $this->allows($type, $value) ? $this->process($type, $value) : null;
+
+ if (($response === null) && ($this->successor !== null)) {
+ $response = $this->successor->handle($type, $value);
+ }
+
+ if ($response === null) {
+ return $value;
+ }
+
+ return $response;
+ }
+
+ /**
+ * Process the data. Return null to send to following handler.
+ *
+ * @return mixed|null
+ */
+ abstract protected function process($type, $value);
+
+ /**
+ * Will define whether this request will be handled by this handler (e.g. check on object type)
+ */
+ abstract protected function allows($type, $value): bool;
+}
diff --git a/src/Importer/Transformer/Handler/StringToArrayHandler.php b/src/Importer/Transformer/Handler/StringToArrayHandler.php
new file mode 100644
index 00000000..67649595
--- /dev/null
+++ b/src/Importer/Transformer/Handler/StringToArrayHandler.php
@@ -0,0 +1,23 @@
+format = $format;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function process($type, $value)
+ {
+ return \DateTime::createFromFormat($this->format, $value);
+ }
+
+ protected function allows($type, $value): bool
+ {
+ return $type === 'datetime';
+ }
+}
diff --git a/src/Importer/Transformer/Handler/StringToFloatHandler.php b/src/Importer/Transformer/Handler/StringToFloatHandler.php
new file mode 100644
index 00000000..8f92811e
--- /dev/null
+++ b/src/Importer/Transformer/Handler/StringToFloatHandler.php
@@ -0,0 +1,23 @@
+generator = $generator;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function handle($type, $value)
+ {
+ if (null === $this->handler) {
+ $this->registerHandlers();
+ }
+
+ return $this->handler->handle($type, $value);
+ }
+
+ private function registerHandlers(): void
+ {
+ foreach ($this->generator->getIterator() as $key => $handler) {
+ if ($key === 0) {
+ $this->handler = $handler;
+
+ continue;
+ }
+
+ $this->handler->setSuccessor($handler);
+ }
+ }
+}
diff --git a/src/Importer/Transformer/TransformerPoolInterface.php b/src/Importer/Transformer/TransformerPoolInterface.php
new file mode 100644
index 00000000..eded9ebf
--- /dev/null
+++ b/src/Importer/Transformer/TransformerPoolInterface.php
@@ -0,0 +1,13 @@
+resourceProductFactory = $productFactory;
+ $this->resourceTaxonFactory = $taxonFactory;
+ $this->productRepository = $productRepository;
+ $this->taxonRepository = $taxonRepository;
+ $this->metadataValidator = $metadataValidator;
+ $this->propertyAccessor = $propertyAccessor;
+ $this->productAttributeRepository = $productAttributeRepository;
+ $this->productAttributeValueFactory = $productAttributeValueFactory;
+ $this->attributeCodesProvider = $attributeCodesProvider;
+ $this->headerKeys = $headerKeys;
+ $this->slugGenerator = $slugGenerator;
+ $this->transformerPool = $transformerPool;
+ $this->manager = $manager;
+ $this->channelRepository = $channelRepository;
+ $this->productTaxonFactory = $productTaxonFactory;
+ $this->productTaxonRepository = $productTaxonRepository;
+ $this->productImageFactory = $productImageFactory;
+ $this->productImageRepository = $productImageRepository;
+ $this->imageTypesProvider = $imageTypesProvider;
+ $this->productVariantFactory = $productVariantFactory;
+ $this->productVariantRepository = $productVariantRepository;
+ $this->channelPricingFactory = $channelPricingFactory;
+ $this->channelPricingRepository = $channelPricingRepository;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function process(array $data): void
+ {
+ $this->attrCode = $this->attributeCodesProvider->getAttributeCodesList();
+ $this->imageCode = $this->imageTypesProvider->getProductImagesCodesWithPrefixList();
+
+ $this->headerKeys = \array_merge($this->headerKeys, $this->attrCode);
+ $this->headerKeys = \array_merge($this->headerKeys, $this->imageCode);
+ $this->metadataValidator->validateHeaders($this->headerKeys, $data);
+
+ $product = $this->getProduct($data['Code']);
+
+ $this->setDetails($product, $data);
+ $this->setVariant($product, $data);
+ $this->setAttributesData($product, $data);
+ $this->setMainTaxon($product, $data);
+ $this->setTaxons($product, $data);
+ $this->setChannel($product, $data);
+ $this->setImage($product, $data);
+
+ $this->productRepository->add($product);
+ }
+
+ private function getProduct(string $code): ProductInterface
+ {
+ /** @var ProductInterface|null $product */
+ $product = $this->productRepository->findOneBy(['code' => $code]);
+ if (null === $product) {
+ /** @var ProductInterface $product */
+ $product = $this->resourceProductFactory->createNew();
+ $product->setCode($code);
+ }
+
+ return $product;
+ }
+
+ private function getProductVariant(string $code): ProductVariantInterface
+ {
+ /** @var ProductVariantInterface|null $productVariant */
+ $productVariant = $this->productVariantRepository->findOneBy(['code' => $code]);
+ if ($productVariant === null) {
+ /** @var ProductVariantInterface $productVariant */
+ $productVariant = $this->productVariantFactory->createNew();
+ $productVariant->setCode($code);
+ }
+
+ return $productVariant;
+ }
+
+ private function setMainTaxon(ProductInterface $product, array $data): void
+ {
+ /** @var Taxon|null $taxon */
+ $taxon = $this->taxonRepository->findOneBy(['code' => $data['Main_taxon']]);
+ if ($taxon === null) {
+ return;
+ }
+
+ /** @var ProductInterface $product */
+ $product->setMainTaxon($taxon);
+
+ $this->addTaxonToProduct($product, $data['Main_taxon']);
+ }
+
+ private function setTaxons(ProductInterface $product, array $data): void
+ {
+ $taxonCodes = \explode('|', $data['Taxons']);
+ foreach ($taxonCodes as $taxonCode) {
+ if ($taxonCode !== $data['Main_taxon']) {
+ $this->addTaxonToProduct($product, $taxonCode);
+ }
+ }
+ }
+
+ private function setAttributesData(ProductInterface $product, array $data): void
+ {
+ foreach ($this->attrCode as $attrCode) {
+ $attributeValue = $product->getAttributeByCodeAndLocale($attrCode);
+
+ if (empty($data[$attrCode])) {
+ if ($attributeValue !== null) {
+ $product->removeAttribute($attributeValue);
+ }
+
+ continue;
+ }
+
+ if ($attributeValue !== null) {
+ if (null !== $this->transformerPool) {
+ $data[$attrCode] = $this->transformerPool->handle(
+ $attributeValue->getType(),
+ $data[$attrCode]
+ );
+ }
+
+ $attributeValue->setValue($data[$attrCode]);
+
+ continue;
+ }
+
+ $this->setAttributeValue($product, $data, $attrCode);
+ }
+ }
+
+ private function setDetails(ProductInterface $product, array $data): void
+ {
+ $product->setCurrentLocale($data['Locale']);
+ $product->setFallbackLocale($data['Locale']);
+
+ $product->setName(substr($data['Name'], 0, 255));
+ $product->setEnabled((bool) $data['Enabled']);
+ $product->setDescription($data['Description']);
+ $product->setShortDescription(substr($data['Short_description'], 0, 255));
+ $product->setMetaDescription(substr($data['Meta_description'], 0, 255));
+ $product->setMetaKeywords(substr($data['Meta_keywords'], 0, 255));
+ $product->setSlug($product->getSlug() ?: $this->slugGenerator->generate($product->getName()));
+ }
+
+ private function setVariant(ProductInterface $product, array $data): void
+ {
+ $productVariant = $this->getProductVariant($product->getCode());
+ $productVariant->setCurrentLocale($data['Locale']);
+ $productVariant->setCurrentLocale($data['Locale']);
+ $productVariant->setName(substr($data['Name'], 0, 255));
+
+ $channels = \explode('|', $data['Channels']);
+ foreach ($channels as $channelCode) {
+ $channelPricing = $this->channelPricingRepository->findOneBy([
+ 'channelCode' => $channelCode,
+ 'productVariant' => $productVariant,
+ ]);
+
+ if (null === $channelPricing) {
+ /** @var ChannelPricingInterface $channelPricing */
+ $channelPricing = $this->channelPricingFactory->createNew();
+ $channelPricing->setChannelCode($channelCode);
+ $productVariant->addChannelPricing($channelPricing);
+ }
+
+ $channelPricing->setPrice((int) $data['Price']);
+ $channelPricing->setOriginalPrice((int) $data['Price']);
+ }
+
+ $product->addVariant($productVariant);
+ }
+
+ private function setAttributeValue(ProductInterface $product, array $data, string $attrCode): void
+ {
+ /** @var ProductAttribute $productAttr */
+ $productAttr = $this->productAttributeRepository->findOneBy(['code' => $attrCode]);
+ /** @var ProductAttributeValueInterface $attr */
+ $attr = $this->productAttributeValueFactory->createNew();
+ $attr->setAttribute($productAttr);
+ $attr->setProduct($product);
+ $attr->setLocaleCode($product->getTranslation()->getLocale());
+
+ if (null !== $this->transformerPool) {
+ $data[$attrCode] = $this->transformerPool->handle($productAttr->getType(), $data[$attrCode]);
+ }
+
+ $attr->setValue($data[$attrCode]);
+ $product->addAttribute($attr);
+ $this->manager->persist($attr);
+ }
+
+ private function setChannel(ProductInterface $product, array $data): void
+ {
+ $channels = \explode('|', $data['Channels']);
+ foreach ($channels as $channelCode) {
+ $channel = $this->channelRepository->findOneBy(['code' => $channelCode]);
+ if ($channel === null) {
+ continue;
+ }
+ $product->addChannel($channel);
+ }
+ }
+
+ private function addTaxonToProduct(ProductInterface $product, string $taxonCode): void
+ {
+ /** @var Taxon|null $taxon */
+ $taxon = $this->taxonRepository->findOneBy(['code' => $taxonCode]);
+ if ($taxon === null) {
+ return;
+ }
+
+ $productTaxon = $this->productTaxonRepository->findOneByProductCodeAndTaxonCode(
+ $product->getCode(),
+ $taxon->getCode()
+ );
+
+ if (null !== $productTaxon) {
+ return;
+ }
+
+ /** @var ProductTaxonInterface $productTaxon */
+ $productTaxon = $this->productTaxonFactory->createNew();
+ $productTaxon->setTaxon($taxon);
+ $product->addProductTaxon($productTaxon);
+ }
+
+ private function setImage(ProductInterface $product, array $data): void
+ {
+ $productImageCodes = $this->imageTypesProvider->getProductImagesCodesList();
+ foreach ($productImageCodes as $imageType) {
+ /** @var ProductImageInterface $productImage */
+ $productImageByType = $product->getImagesByType($imageType);
+
+ // remove old images if import is empty
+ foreach ($productImageByType as $productImage) {
+ if (empty($data[ImageTypesProvider::IMAGES_PREFIX . $imageType])) {
+ if ($productImage !== null) {
+ $product->removeImage($productImage);
+ }
+
+ continue;
+ }
+ }
+
+ if (empty($data[ImageTypesProvider::IMAGES_PREFIX . $imageType])) {
+ continue;
+ }
+
+ if (count($productImageByType) === 0) {
+ /** @var ProductImageInterface $productImage */
+ $productImage = $this->productImageFactory->createNew();
+ } else {
+ $productImage = $productImageByType->first();
+ }
+
+ $productImage->setType($imageType);
+ $productImage->setPath($data[ImageTypesProvider::IMAGES_PREFIX . $imageType]);
+ $product->addImage($productImage);
+ }
+
+ // create image if import has new one
+ foreach ($this->imageTypesProvider->extractImageTypeFromImport(\array_keys($data)) as $imageType) {
+ if (\in_array($imageType, $productImageCodes) || empty($data[ImageTypesProvider::IMAGES_PREFIX . $imageType])) {
+ continue;
+ }
+
+ /** @var ProductImageInterface $productImage */
+ $productImage = $this->productImageFactory->createNew();
+ $productImage->setType($imageType);
+ $productImage->setPath($data[ImageTypesProvider::IMAGES_PREFIX . $imageType]);
+ $product->addImage($productImage);
+ }
+ }
+}
diff --git a/src/Repository/ProductImageImageRepository.php b/src/Repository/ProductImageImageRepository.php
new file mode 100644
index 00000000..858b06e2
--- /dev/null
+++ b/src/Repository/ProductImageImageRepository.php
@@ -0,0 +1,19 @@
+createQueryBuilder('p')
+ ->groupBy('p.type')
+ ->addGroupBy('p.id')
+ ->getQuery()
+ ->getArrayResult();
+ }
+}
diff --git a/src/Repository/ProductImageRepositoryInterface.php b/src/Repository/ProductImageRepositoryInterface.php
new file mode 100644
index 00000000..e3590459
--- /dev/null
+++ b/src/Repository/ProductImageRepositoryInterface.php
@@ -0,0 +1,12 @@
+productAttributeRepository = $productAttributeRepository;
+ }
+
+ public function getAttributeCodesList(): array
+ {
+ $attrSlug = [];
+ $productAttr = $this->productAttributeRepository->findBy([], ['id' => 'ASC']);
+ /** @var ProductAttribute $attr */
+ foreach ($productAttr as $attr) {
+ if (!empty($attr->getCode())) {
+ $attrSlug[] = $attr->getCode();
+ }
+ }
+
+ return $attrSlug;
+ }
+}
diff --git a/src/Service/AttributeCodesProviderInterface.php b/src/Service/AttributeCodesProviderInterface.php
new file mode 100644
index 00000000..38415d07
--- /dev/null
+++ b/src/Service/AttributeCodesProviderInterface.php
@@ -0,0 +1,13 @@
+productImageRepository = $productImageRepository;
+ }
+
+ public function getProductImagesCodesList(): array
+ {
+ return $this->extractProductImagesType();
+ }
+
+ public function getProductImagesCodesWithPrefixList(): array
+ {
+ return $this->extractProductImagesType(self::IMAGES_PREFIX);
+ }
+
+ public function extractImageTypeFromImport(array $keys): array
+ {
+ $keys = \array_filter($keys, function ($value) {
+ return \mb_substr($value, 0, \mb_strlen(self::IMAGES_PREFIX)) === self::IMAGES_PREFIX;
+ });
+
+ $keys = \array_map([self::class, 'extractTypeOfImage'], $keys);
+
+ return $keys;
+ }
+
+ private function extractProductImagesType(string $prefix = ''): array
+ {
+ $attrSlug = [];
+ $productImages = $this->productImageRepository->findTypes();
+ foreach ($productImages as $attr) {
+ if (empty($attr['type'])) {
+ continue;
+ }
+
+ $attrSlug[] = $prefix . $attr['type'];
+ }
+
+ $attrSlug = \array_unique($attrSlug);
+
+ return $attrSlug;
+ }
+
+ private function extractTypeOfImage(string $value): string
+ {
+ return \mb_substr($value, \mb_strlen(self::IMAGES_PREFIX));
+ }
+}
diff --git a/src/Service/ImageTypesProviderInterface.php b/src/Service/ImageTypesProviderInterface.php
new file mode 100644
index 00000000..8faabd03
--- /dev/null
+++ b/src/Service/ImageTypesProviderInterface.php
@@ -0,0 +1,14 @@
+repository->findBy(['code' => $productId]);
+ Assert::assertNotNull($product);
+ }
+ }
+}
diff --git a/tests/Behat/Context/ProductsContext.php b/tests/Behat/Context/ProductsContext.php
new file mode 100644
index 00000000..d09cb417
--- /dev/null
+++ b/tests/Behat/Context/ProductsContext.php
@@ -0,0 +1,158 @@
+productIndexPage = $productIndexPage;
+ $this->productContext = $productContext;
+
+ parent::__construct($session, $parameters, $router);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getRouteName(): string
+ {
+ return 'sylius_admin_product_index';
+ }
+
+ /**
+ * @When I import product data from :file :format file
+ */
+ public function iImportProductDataFromCsvFile(string $file, string $format): void
+ {
+ $this->productIndexPage->importData($file, $format);
+ }
+
+ /**
+ * @When I open the product admin index page
+ */
+ public function iOpenTheProductIndexPage(): void
+ {
+ $this->productIndexPage->open();
+ }
+
+ /**
+ * @Then I should see an export button
+ */
+ public function iShouldSeeExportButton(): void
+ {
+ Assert::assertEquals(
+ 'Export',
+ $this->getElement('export_button_text')->getText()
+ );
+ }
+
+ /**
+ * @Then I click on :element
+ */
+ public function iClickOn(string $element): void
+ {
+ $page = $this->getSession()->getPage();
+ $findName = $page->find('css', $element);
+ if (!$findName) {
+ throw new ElementNotFoundException($element . ' could not be found');
+ }
+ $findName->click();
+ }
+
+ /**
+ * @Then I should see a link to export products to CSV
+ */
+ public function iShouldSeeExportCSVLink(): void
+ {
+ Assert::assertContains(
+ 'CSV',
+ $this->getElement('export_links')->find('css', 'a.item')->getText()
+ );
+ }
+
+ /**
+ * @Then the product :product should appear in the registry
+ */
+ public function theProductShouldAppearInTheRegistry(string $productname): void
+ {
+ $product = $this->productContext->getProductByName($productname);
+
+ Assert::assertNotNull(
+ $product,
+ sprintf('Product with name "%s" does not exist', $productname)
+ );
+ }
+
+ /**
+ * @Then :amount products should be in the registry
+ */
+ public function amountProductsShouldBeInTheRegistry(int $amount): void
+ {
+ $productCount = $this->productContext->getProductCount();
+
+ Assert::assertEquals(
+ $amount,
+ $productCount,
+ 'expected value differs from actual value ' . $amount . ' !== ' . $productCount
+ );
+ }
+
+ /**
+ * @When I go to :hp homepage
+ */
+ public function goToSpecificHomepage(string $hp): void
+ {
+ $this->getSession(null)->visit($hp);
+ }
+
+ /**
+ * Checks that response body contains specific text.
+ *
+ * @Then response should contain :text
+ */
+ public function theResponseShouldContain(string $text): void
+ {
+ $responseText = $this->getSession()->getPage()->getContent();
+
+ if (strpos($responseText, $text) !== false) {
+ return;
+ }
+
+ throw new ResponseTextException(sprintf("Response '%s' does not contain: '%s'", $responseText, $text), $this->getDriver());
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getDefinedElements(): array
+ {
+ return array_merge(parent::getDefinedElements(), [
+ 'export_button_text' => '.buttons div.dropdown span.text',
+ 'export_links' => '.buttons div.dropdown div.menu',
+ ]);
+ }
+}
diff --git a/tests/Behat/Resources/fixtures/products.csv b/tests/Behat/Resources/fixtures/products.csv
new file mode 100644
index 00000000..67623e67
--- /dev/null
+++ b/tests/Behat/Resources/fixtures/products.csv
@@ -0,0 +1,3 @@
+Code,Locale,Name,Description,Short_description,Meta_description,Meta_keywords,Main_taxon,Taxons,Channels,Enabled,Price
+123456,en_US,Product 1,Description 1,Short description 1,Meta description 1,Meta keywords 1,,,,1,22
+222333,en_US,Product 2,Description 2,Short description 2,Meta description 2,Meta keywords 2,,,,1,33
diff --git a/tests/Behat/Resources/fixtures/products_attr.csv b/tests/Behat/Resources/fixtures/products_attr.csv
new file mode 100644
index 00000000..6d503521
--- /dev/null
+++ b/tests/Behat/Resources/fixtures/products_attr.csv
@@ -0,0 +1,3 @@
+Code,Locale,Name,Description,Short_description,Meta_description,Meta_keywords,Main_taxon,Taxons,Channels,Enabled,Price,Attribute_text,Attribute_textarea,Attribute_percent
+123456,en_US,Product 1,Description 1,Short description 1,Meta description 1,Meta keywords 1,,,,1,25,Banana,Attribute textarea
two,0.22
+222333,en_US,Product 2,Description 2,Short description 2,Meta description 2,Meta keywords 2,,,,1,26,,,
diff --git a/tests/Behat/Resources/fixtures/products_update.csv b/tests/Behat/Resources/fixtures/products_update.csv
new file mode 100644
index 00000000..5c93c4f0
--- /dev/null
+++ b/tests/Behat/Resources/fixtures/products_update.csv
@@ -0,0 +1,2 @@
+Code,Locale,Name,Description,Short_description,Meta_description,Meta_keywords,Main_taxon,Taxons,Channels,Enabled,Price
+222333,en_US,Product 2 update,Description 2,Short description 2,Meta description 2,Meta keywords 2,,,,1,42
diff --git a/tests/Behat/Resources/services.xml b/tests/Behat/Resources/services.xml
index 1dd51cc9..4f27134b 100644
--- a/tests/Behat/Resources/services.xml
+++ b/tests/Behat/Resources/services.xml
@@ -28,6 +28,13 @@
+
+
+
+
+
+
+
@@ -77,6 +84,14 @@
%behat.files_directory%
+
+
+
+
+
+
+ %behat.files_directory%
+
sylius_admin_country_index
@@ -94,5 +109,9 @@
sylius_admin_payment_method_index
%behat.files_directory%
+
+ sylius_admin_product_index
+ %behat.files_directory%
+
diff --git a/tests/Behat/Resources/suites.yml b/tests/Behat/Resources/suites.yml
index f3bd5156..5272122e 100644
--- a/tests/Behat/Resources/suites.yml
+++ b/tests/Behat/Resources/suites.yml
@@ -80,6 +80,28 @@ default:
filters:
tags: "@managing_customer_groups && @ui"
+ ui_managing_products:
+ contexts:
+ - sylius.behat.context.hook.doctrine_orm
+
+ - sylius.behat.context.transform.product
+ - sylius.behat.context.transform.shared_storage
+
+ - sylius.behat.context.setup.geographical
+ - sylius.behat.context.setup.admin_security
+
+ - sylius.behat.context.ui.admin.managing_products
+ - sylius.behat.context.ui.admin.notification
+
+ - test.behat.context.notification_context
+ - test.behat.context.products_context
+
+ - sylius.behat.context.setup.locale
+ - sylius.behat.context.setup.product
+ - sylius.behat.context.setup.product_attribute
+ filters:
+ tags: "@managing_products && @ui"
+
cli_managing_countries:
contexts:
- sylius.behat.context.hook.doctrine_orm
@@ -131,6 +153,14 @@ default:
filters:
tags: "@managing_customers && @cli_importer_exporter"
+ cli_managing_products:
+ contexts:
+ - sylius.behat.context.hook.doctrine_orm
+ - test.behat.context.cli_products_context
+ - sylius.behat.context.cli.installer
+ filters:
+ tags: "@managing_products && @cli_importer_exporter"
+
extensions:
FriendsOfBehat\SuiteSettingsExtension:
paths:
diff --git a/web/media/image/.gitkeep b/web/media/image/.gitkeep
new file mode 100644
index 00000000..e69de29b