diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b0cedbe..56c999c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,30 +2,14 @@ name: Tests on: push: - branches: - - '**' pull_request: - branches: - - master + types: [ opened, reopened ] jobs: - pre_job: - runs-on: ubuntu-latest - outputs: - should_skip: ${{ steps.skip_check.outputs.should_skip }} - steps: - - id: skip_check - uses: fkirc/skip-duplicate-actions@v5 - with: - concurrent_skipping: always - skip_after_successful_duplicate: true - do_not_skip: '["pull_request"]' tests: runs-on: ubuntu-latest name: Tests - needs: pre_job - if: needs.pre_job.outputs.should_skip != 'true' strategy: fail-fast: false diff --git a/README.md b/README.md index 0e90d7d..224f87a 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Importing entities with preview and edit features for Symfony. * [Controller-specific templates](#controller-specific-templates) * [Main layout](#main-layout) * [Additional data](#additional-data) +* [Updating entities](#updating-entities) * [Importing data to array field](#importing-data-to-array-field) * [Full example of CSV file](#full-example-of-csv-file) @@ -360,6 +361,25 @@ protected function prepareMatrixEditView(FormInterface $form, Matrix $matrix, bo } ``` +## Updating entities + +If you want to update your entities: +- Set `allowOverrideEntity` to `true` in your import configuration file. +- Then in your import file: + - Add `entity_id` in header and: + - Add entity ID to row + - Leave it empty (if you want to set it manually or import it as new record) + - Or if you don't want to add `entity_id` header, you can still manually set each entity to override. + +#### CSV file + +```csv +entity_id,user_name +2,user_1 +,user_2 +10,user_3 +``` + ## Importing data to array field If your entity has an array field, and you want to import data from CSV file to it, this is how you can do it. @@ -434,8 +454,8 @@ user_3,SUPER_ADMIN ## Full example of CSV file ```csv -user_name,age,email,roles,country:en,name:pl -user_1,21,user_1@test.com,USER&ADMIN&SUPER_ADMIN,Poland,Polska -user_2,34,user_2@test.com,USER,England,Anglia -user_3,56,user_3@test.com,SUPER_ADMIN,Germany,Niemcy +entity_id,user_name,age,email,roles,country:en,name:pl +1,user_1,21,user_1@test.com,USER&ADMIN&SUPER_ADMIN,Poland,Polska +3, user_2,34,user_2@test.com,USER,England,Anglia +,user_3,56,user_3@test.com,SUPER_ADMIN,Germany,Niemcy ``` diff --git a/UPGRADE.md b/UPGRADE.md index a96cc5f..246b4b9 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,70 +1,34 @@ -UPGRADE TO 3.6 -======================= +# UPGRADE TO 3.7 -Import data to array --------------- +### Preset entity id before updating - [go to the documentation](README.md#updating-entities) -* By default, allowed file extensions are set to `'csv', 'xls', 'xlsx', 'ods'`. -However, if you want to change it, you can override this method in your import configuration. +# UPGRADE TO 3.6 -```php -public function getAllowedFileExtensions(): array -{ - return ['csv', 'xls', 'xlsx', 'ods']; -} -``` +### Set allowed file extensions - [go to the documentation](README.md#set-allowed-file-extensions) -UPGRADE TO 3.5 -======================= - -Import data to array --------------- -* If your entity has an array field, and you want to import data from CSV file to it, it is now possible. - -```php -use JG\BatchEntityImportBundle\Form\Type\ArrayTextType; -use JG\BatchEntityImportBundle\Model\Form\FormFieldDefinition; - -public function getFieldsDefinitions(): array -{ - return [ - 'roles' => new FormFieldDefinition( - ArrayTextType::class, - [ - 'separator' => '&', - ] - ), - ]; -} -``` +# UPGRADE TO 3.5 +### Import data to array - [go to the documentation](README.md#importing-data-to-array-field) -UPGRADE TO 3.1 -======================= +# UPGRADE TO 3.1 -CSV File --------------- -* Now CSV file can contain spaces and dashes as a header name, for example "my column name" or "my-column-name". +## CSV File +- Now CSV file can contain spaces and dashes as a header name, for example "my column name" or "my-column-name". -Import Configuration class --------------- -* When header name contains spaces we should use underscores instead of spaces when defining fields names in fields definitions and in constraints. +## Import Configuration class +- When header name contains spaces we should use underscores instead of spaces when defining fields names in fields definitions and in constraints. -UPGRADE TO 3.0 -======================= +# UPGRADE TO 3.0 -Controller --------------- +## Controller * Passing configuration class by `getSubscribedServices()` was removed. Now it is only possible by autoconfiguration. -UPGRADE TO 2.5 -======================= +# UPGRADE TO 2.5 -Import Configuration class --------------- -* Added new validator to check matrix record data uniqueness in database. +## Import Configuration class +- Added new validator to check matrix record data uniqueness in database. ```php use JG\BatchEntityImportBundle\Validator\Constraints\DatabaseEntityUnique; @@ -76,12 +40,10 @@ public function getMatrixConstraints(): array } ``` -UPGRADE TO 2.4 -======================= +# UPGRADE TO 2.4 -Import Configuration class --------------- -* Added new validator to check matrix record data uniqueness. +## Import Configuration class +- Added new validator to check matrix record data uniqueness. ```php use JG\BatchEntityImportBundle\Validator\Constraints\MatrixRecordUnique; @@ -93,33 +55,27 @@ public function getMatrixConstraints(): array } ``` -Controller --------------- -* List of options passed to form in `createMatrixForm()` method, should contain new `constraints` element: +## Controller +- List of options passed to form in `createMatrixForm()` method, should contain new `constraints` element: `'constraints' => $importConfiguration->getMatrixConstraints()` -UPGRADE TO 2.3 -======================= +# UPGRADE TO 2.3 -Controller --------------- -* Passing configuration class by `getSubscribedServices()` method is not needed anymore and will be removed in the future. -* To make sure that configuration class will be injected automatically: - * Interface `JG\BatchEntityImportBundle\Controller\ImportConfigurationAutoInjectInterface` should be implemented. - * Trait `JG\BatchEntityImportBundle\Controller\ImportConfigurationAutoInjectTrait` should be used to add needed methods. +## Controller +- Passing configuration class by `getSubscribedServices()` method is not needed anymore and will be removed in the future. +- To make sure that configuration class will be injected automatically: + - Interface `JG\BatchEntityImportBundle\Controller\ImportConfigurationAutoInjectInterface` should be implemented. + - Trait `JG\BatchEntityImportBundle\Controller\ImportConfigurationAutoInjectTrait` should be used to add needed methods. -UPGRADE TO 2.2 -======================= +# UPGRADE TO 2.2 -Import Configuration class --------------- -* Now configuration class should be always registered as a service: +## Import Configuration class +- Now configuration class should be always registered as a service: ```yaml services: App\Model\ImportConfiguration\UserImportConfiguration: ~ ``` -Controller --------------- -* Entity Manager is no longer passed as an argument of actions. +## Controller +- Entity Manager is no longer passed as an argument of actions. diff --git a/src/Controller/BaseImportControllerTrait.php b/src/Controller/BaseImportControllerTrait.php index 1fc9c15..dc9963a 100644 --- a/src/Controller/BaseImportControllerTrait.php +++ b/src/Controller/BaseImportControllerTrait.php @@ -89,7 +89,6 @@ protected function prepareMatrixEditView(FormInterface $form, Matrix $matrix, bo $this->getMatrixEditTemplateName(), [ 'header_info' => $matrix->getHeaderInfo($configuration->getEntityClassName()), - 'data' => $matrix->getRecords(), 'form' => $form->createView(), 'importConfiguration' => $configuration, ] diff --git a/src/Form/Type/MatrixRecordType.php b/src/Form/Type/MatrixRecordType.php index c76faaa..38c13f2 100644 --- a/src/Form/Type/MatrixRecordType.php +++ b/src/Form/Type/MatrixRecordType.php @@ -19,6 +19,8 @@ use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormEvent; use Symfony\Component\Form\FormEvents; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; use Symfony\Component\OptionsResolver\Exception\AccessException; use Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -101,4 +103,17 @@ private function addField(array $fieldDefinitions, string $columnName, FormEvent ? $event->getForm()->add($columnName, $definition->getClass(), $definition->getOptions()) : $event->getForm()->add($columnName, TextType::class); } + + public function finishView(FormView $view, FormInterface $form, array $options): void + { + /** @var MatrixRecord $entity */ + $entity = $form->getData(); + $selectedValue = $entity->entityId; + + foreach ($view['entity']->vars['choices'] ?? [] as $index => $choice) { + if ($choice->value === $selectedValue) { + $view['entity']->vars['choices'][$index]->attr['selected'] = 'selected'; + } + } + } } diff --git a/src/Model/Matrix/Matrix.php b/src/Model/Matrix/Matrix.php index 8d6d7e7..dc431d8 100644 --- a/src/Model/Matrix/Matrix.php +++ b/src/Model/Matrix/Matrix.php @@ -12,6 +12,7 @@ class Matrix { private const RESERVED_ENTITY_COLUMN_NAME = 'entity'; + private const RESERVED_ENTITY_ID_COLUMN_NAME = 'entity_id'; #[Assert\All([ new Assert\NotBlank(), new Assert\Type('string'), @@ -33,9 +34,9 @@ public function __construct(array $header = [], array $recordsData = []) $this->header = $this->clearHeader($header); foreach ($recordsData as $data) { - $data = $this->clearRecordData($data); - if ($data) { - $this->records[] = new MatrixRecord($data); + $clearedData = $this->clearRecordData($data); + if ($clearedData) { + $this->records[] = new MatrixRecord($clearedData, $this->getEntityIdValue($data)); } } } @@ -65,6 +66,17 @@ public function getHeaderInfo(string $className): array return $info; } + private function getEntityIdValue(array $data): int|string|null + { + foreach ($data as $name => $value) { + if (self::RESERVED_ENTITY_ID_COLUMN_NAME === $name) { + return $value; + } + } + + return null; + } + private function clearHeader(array $header): array { $header = array_values( @@ -81,6 +93,6 @@ private function clearRecordData(array $data): array private function isColumnNameValid(?string $name): bool { - return !empty(trim((string) $name)) && self::RESERVED_ENTITY_COLUMN_NAME !== $name; + return !empty(trim((string) $name)) && !\in_array($name, [self::RESERVED_ENTITY_COLUMN_NAME, self::RESERVED_ENTITY_ID_COLUMN_NAME], true); } } diff --git a/src/Model/Matrix/MatrixFactory.php b/src/Model/Matrix/MatrixFactory.php index 9f74ee7..5699e58 100644 --- a/src/Model/Matrix/MatrixFactory.php +++ b/src/Model/Matrix/MatrixFactory.php @@ -8,6 +8,7 @@ use JG\BatchEntityImportBundle\Service\CsvDelimiterDetector; use PhpOffice\PhpSpreadsheet\Reader\BaseReader; use PhpOffice\PhpSpreadsheet\Reader\Csv; +use PhpOffice\PhpSpreadsheet\Reader\Xls; use PhpOffice\PhpSpreadsheet\Reader\Xlsx; use Symfony\Component\HttpFoundation\File\UploadedFile; @@ -55,7 +56,7 @@ private static function getReader(UploadedFile $file): BaseReader if ($reader instanceof Csv) { $detectedDelimiter = (new CsvDelimiterDetector())->detect($file->getContent()); $reader->setDelimiter($detectedDelimiter->value); - } elseif ($reader instanceof Xlsx) { + } elseif ($reader instanceof Xls || $reader instanceof Xlsx) { $reader->setIgnoreRowsWithNoCells(true); } diff --git a/src/Model/Matrix/MatrixRecord.php b/src/Model/Matrix/MatrixRecord.php index c633671..4c5ea53 100644 --- a/src/Model/Matrix/MatrixRecord.php +++ b/src/Model/Matrix/MatrixRecord.php @@ -9,7 +9,7 @@ class MatrixRecord private ?object $entity = null; private array $data = []; - public function __construct(array $data = []) + public function __construct(array $data = [], public readonly int|string|null $entityId = null) { foreach ($data as $name => $value) { if (!empty(\trim((string) $name))) { diff --git a/tests/Controller/ImportControllerTraitTest.php b/tests/Controller/ImportControllerTraitTest.php index 7bb1601..a6b4326 100644 --- a/tests/Controller/ImportControllerTraitTest.php +++ b/tests/Controller/ImportControllerTraitTest.php @@ -66,6 +66,28 @@ public function testUpdateExistingRecord( $this->assertEntityValues($expectedDefaultValues, $updatedEntityId); $this->submitSelectFileForm(__DIR__ . '/../Fixtures/Resources/test_updated_data.csv'); + + $this->assertSame( + '2', + $this->client->getCrawler()->filterXpath('//select[@name="matrix[records][0][entity]"]/option[@selected]')->attr('value'), + ); + $this->assertSame( + 'test', + $this->client->getCrawler()->filterXpath('//input[@name="matrix[records][0][test_private_property]"]')->attr('value'), + ); + $this->assertSame( + 'lorem ipsum', + $this->client->getCrawler()->filterXpath('//input[@name="matrix[records][0][test-private-property2]"]')->attr('value'), + ); + $this->assertSame( + 'qwerty', + $this->client->getCrawler()->filterXpath('//input[@name="matrix[records][0][test_public_property]"]')->attr('value'), + ); + $this->assertSame( + 'arr_val_1000|array_val_1001', + $this->client->getCrawler()->filterXpath('//input[@name="matrix[records][0][test_array_field]"]')->attr('value'), + ); + $this->client->submitForm('btn-submit', [ 'matrix' => [ 'records' => [ diff --git a/tests/Fixtures/Resources/test.xls b/tests/Fixtures/Resources/test.xls new file mode 100644 index 0000000..8986abd Binary files /dev/null and b/tests/Fixtures/Resources/test.xls differ diff --git a/tests/Fixtures/Resources/test.xlsx b/tests/Fixtures/Resources/test.xlsx new file mode 100644 index 0000000..f0eb0c0 Binary files /dev/null and b/tests/Fixtures/Resources/test.xlsx differ diff --git a/tests/Fixtures/Resources/test_import_with_rows_without_cells.xlsx b/tests/Fixtures/Resources/test_import_with_rows_without_cells.xlsx new file mode 100644 index 0000000..31f2e8f Binary files /dev/null and b/tests/Fixtures/Resources/test_import_with_rows_without_cells.xlsx differ diff --git a/tests/Fixtures/Resources/test_updated_data.csv b/tests/Fixtures/Resources/test_updated_data.csv index 3b02cd0..26f72ba 100644 --- a/tests/Fixtures/Resources/test_updated_data.csv +++ b/tests/Fixtures/Resources/test_updated_data.csv @@ -1,2 +1,2 @@ -test_private_property,test-private-property2,test public property,test_array_field -test,lorem ipsum,qwerty,arr_val_1000|array_val_1001 +test_private_property,test-private-property2,test public property,test_array_field,entity_id +test,lorem ipsum,qwerty,arr_val_1000|array_val_1001,2 diff --git a/tests/Model/FileImportTest.php b/tests/Model/FileImportTest.php index 7e9d986..2fd4ca5 100644 --- a/tests/Model/FileImportTest.php +++ b/tests/Model/FileImportTest.php @@ -62,7 +62,9 @@ public function testEmptyFileError(): void $fileImport = new FileImport(['csv', 'xls', 'xlsx', 'ods']); $this->setUploadedFile($fileImport, 'csv', false); - self::assertNotEmpty($this->getErrors($fileImport)); + $errors = $this->getErrors($fileImport); + self::assertCount(1, $errors); + self::assertSame('An empty file is not allowed.', $errors[0]->getMessage()); } /** @@ -73,7 +75,9 @@ public function testInvalidExtensionError(string $extension, array $allowedExten $fileImport = new FileImport($allowedExtensions); $this->setUploadedFile($fileImport, $extension); - self::assertNotEmpty($this->getErrors($fileImport)); + $errors = $this->getErrors($fileImport); + self::assertCount(1, $errors); + self::assertSame('validation.file.extension', $errors[0]->getMessage()); } public static function invalidExtensionsProvider(): Generator @@ -85,14 +89,6 @@ public static function invalidExtensionsProvider(): Generator yield ['csv', []]; } - public function testEmptyContentError(): void - { - $fileImport = new FileImport(['csv', 'xls', 'xlsx', 'ods']); - $fileImport->setFile($this->createUploadedFile('csv', false)); - - self::assertNotEmpty($this->getErrors($fileImport)); - } - private function setUploadedFile(FileImport $fileImport, string $fileExtension, bool $withContent = true): void { $fileImport->setFile($this->createUploadedFile($fileExtension, $withContent)); diff --git a/tests/Model/Matrix/MatrixFactoryTest.php b/tests/Model/Matrix/MatrixFactoryTest.php index c0ec15c..b2c2f31 100644 --- a/tests/Model/Matrix/MatrixFactoryTest.php +++ b/tests/Model/Matrix/MatrixFactoryTest.php @@ -18,13 +18,45 @@ class MatrixFactoryTest extends TestCase { + /** + * @dataProvider importFilesDataProvider + */ + public function testCreateFromRealUploadedFileSuccess(string $file, array $expectedHeader, int $expectedRecordNumber): void + { + $uploadedFile = new UploadedFile($file, $file); + $matrix = MatrixFactory::createFromUploadedFile($uploadedFile); + + $this->assertSame($expectedHeader, $matrix->getHeader()); + $this->assertCount($expectedRecordNumber, $matrix->getRecords()); + } + + public static function importFilesDataProvider(): Generator + { + yield [ + __DIR__ . '/../../Fixtures/Resources/test.csv', + ['test_private_property', 'test-private-property2', 'test_public_property', 'test_array_field'], + 30, + ]; + yield [ + __DIR__ . '/../../Fixtures/Resources/test.xls', + ['test_private_property', 'test-private-property2', 'test_public_property', 'test_array_field'], + 30, + ]; + yield [ + __DIR__ . '/../../Fixtures/Resources/test.xlsx', + ['test_private_property', 'test-private-property2', 'test_public_property', 'test_array_field'], + 30, + ]; + yield [__DIR__ . '/../../Fixtures/Resources/test_import_with_rows_without_cells.xlsx', ['Domain', 'Another', 'Comment'], 24]; + } + /** * @dataProvider dataProvider * * @throws SpreadsheetException * @throws Exception */ - public function testCreateFromUploadedFileSuccess(string $fileExtension, CsvDelimiterEnum $delimiter = CsvDelimiterEnum::COMMA): void + public function testCreateFromGeneratedUploadFileSuccess(string $fileExtension, CsvDelimiterEnum $delimiter = CsvDelimiterEnum::COMMA): void { foreach ($this->contentProvider() as $data) { $file = $this->createFile($fileExtension, $delimiter, \array_merge([$data['header']], $data['records']));