├── .editorconfig ├── .github ├── CONTRIBUTING.md └── workflows │ └── pipeline.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── composer.json ├── database └── migrations │ └── 2025_05_15_131001_create_history_table.php ├── docs └── index.md ├── phpstan-baseline.neon ├── phpstan.neon.dist ├── phpunit.xml.dist ├── pint.json ├── rector.php ├── resources ├── lang │ └── en │ │ └── default.php ├── models │ ├── exportmodel.php │ ├── importmodel.php │ ├── menuexport.php │ └── menuimport.php └── views │ ├── _partials │ ├── export_columns.blade.php │ ├── export_container.blade.php │ ├── export_result.blade.php │ ├── import_columns.blade.php │ ├── import_container.blade.php │ ├── import_result.blade.php │ ├── new_export_popup.blade.php │ └── new_import_popup.blade.php │ └── importexport │ ├── export.blade.php │ ├── import.blade.php │ └── index.blade.php ├── src ├── Classes │ └── ImportExportManager.php ├── Database │ └── Factories │ │ └── HistoryFactory.php ├── Extension.php ├── Http │ ├── Actions │ │ ├── ExportController.php │ │ └── ImportController.php │ └── Controllers │ │ └── ImportExport.php ├── Models │ ├── ExportModel.php │ ├── History.php │ ├── ImportModel.php │ ├── MenuExport.php │ └── MenuImport.php └── Traits │ └── ImportExportHelper.php └── tests ├── Classes └── ImportExportManagerTest.php ├── ExtensionTest.php ├── Http ├── Actions │ ├── ExportControllerTest.php │ └── ImportControllerTest.php └── Controllers │ └── ImportExportTest.php ├── Models ├── MenuExportTest.php └── MenuImportTest.php ├── Pest.php └── _fixtures └── valid_import_file.csv /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | indent_style = space 9 | indent_size = 4 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | 14 | [*.{yml, yaml, json, scss, css}] 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are **welcome** and will be fully **credited**. 4 | 5 | Please read and understand the contribution guide before creating an issue or pull request. 6 | 7 | ## Etiquette 8 | 9 | This project is open source, and as such, the maintainers give their free time to build and maintain the source code 10 | held within. They make the code freely available in the hope that it will be of use to other developers. It would be 11 | extremely unfair for them to suffer abuse or anger for their hard work. 12 | 13 | Please be considerate towards maintainers when raising issues or presenting pull requests. Let's show the world that 14 | developers are civilized and selfless people. 15 | 16 | It's the duty of the maintainer to ensure that all submissions to the project are of sufficient quality to benefit the 17 | project. Many developers have different skillsets, strengths, and weaknesses. Respect the maintainer's decision, and do 18 | not be upset or abusive if your submission is not used. 19 | 20 | ## Viability 21 | 22 | When requesting or submitting new features, first consider whether it might be useful to others. Open source projects 23 | are used by many developers, who may have entirely different needs to your own. Think about whether or not your feature 24 | is likely to be used by other users of the project. 25 | 26 | ## Procedure 27 | 28 | Before filing an issue: 29 | 30 | - Attempt to replicate the problem, to ensure that it wasn't a coincidental incident. 31 | - Check to make sure your feature suggestion isn't already present within the project. 32 | - Check the pull requests tab to ensure that the bug doesn't have a fix in progress. 33 | - Check the pull requests tab to ensure that the feature isn't already in progress. 34 | 35 | Before submitting a pull request: 36 | 37 | - Check the codebase to ensure that your feature doesn't already exist. 38 | - Check the pull requests to ensure that another person hasn't already submitted the feature or fix. 39 | 40 | ## Requirements 41 | 42 | If the project maintainer has any additional requirements, you will find them listed here. 43 | 44 | - **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** 45 | - The easiest way to apply the conventions is to 46 | install [PHP Code Sniffer](https://pear.php.net/package/PHP_CodeSniffer). 47 | 48 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests. 49 | 50 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept 51 | up-to-date. 52 | 53 | - **Consider our release cycle** - We try to follow [SemVer v2.0.0](https://semver.org/). Randomly breaking public APIs 54 | is not an option. 55 | 56 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 57 | 58 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make 59 | multiple intermediate commits while developing, 60 | please [squash them](https://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) 61 | before submitting. 62 | 63 | **Happy coding**! 64 | -------------------------------------------------------------------------------- /.github/workflows/pipeline.yml: -------------------------------------------------------------------------------- 1 | name: Import Export CI Pipeline 2 | 3 | on: [ push, workflow_dispatch ] 4 | 5 | jobs: 6 | php-tests: 7 | strategy: 8 | matrix: 9 | php: [ '8.3', '8.4' ] 10 | uses: tastyigniter/workflows/.github/workflows/php-tests.yml@main 11 | with: 12 | php-version: ${{ matrix.target }} 13 | composer: update --no-interaction --no-progress 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | .php_cs 4 | .php_cs.cache 5 | .phpunit.result.cache 6 | .phpunit.cache 7 | /build 8 | composer.lock 9 | coverage 10 | phpunit.xml 11 | psalm.xml 12 | /vendor 13 | .php-cs-fixer.cache 14 | node_modules/ 15 | npm-debug.log 16 | yarn-error.log -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Igniter Labs Team 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Build Status 3 | Total Downloads 4 | Latest Stable Version 5 | License 6 |

7 | 8 | ## Introduction 9 | 10 | This extension allows you to import or export TastyIgniter records, such as menu items, customers, reservations, and orders. You can export records to a CSV file, make changes to the data, and then import the updated records back into TastyIgniter. 11 | 12 | ## Features 13 | 14 | - Export data into a CSV file. 15 | - Import data in CSV format into TastyIgniter. 16 | 17 | ## Documentation 18 | 19 | More documentation can be found on [here](https://github.com/igniter-labs/ti-ext-importexport/blob/master/docs/index.md). 20 | 21 | ## Changelog 22 | 23 | Please see [CHANGELOG](https://github.com/igniter-labs/ti-ext-importexport/blob/master/CHANGELOG.md) for more information on what has changed recently. 24 | 25 | ## Reporting issues 26 | 27 | If you encounter a bug in this extension, please report it using the [Issue Tracker](https://github.com/igniter-labs/ti-ext-importexport/issues) on GitHub. 28 | 29 | ## Contributing 30 | 31 | Contributions are welcome! Please read [TastyIgniter's contributing guide](https://tastyigniter.com/docs/resources/contribution-guide). 32 | 33 | ## Security vulnerabilities 34 | 35 | For reporting security vulnerabilities, please see [our security policy](https://github.com/igniter-labs/ti-ext-importexport/security/policy). 36 | 37 | ## License 38 | 39 | TastyIgniter ImportExport extension is open-source software licensed under the [MIT license](https://github.com/igniter-labs/ti-ext-importexport/blob/master/LICENSE.md). 40 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "igniterlabs/ti-ext-importexport", 3 | "type": "tastyigniter-package", 4 | "description": "Import/Export Menu Items, Orders, Customers from/into any CSV or Microsoft Excel file to TastyIgniter.", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Igniter Labs Team" 9 | } 10 | ], 11 | "keywords": [ 12 | "tastyigniter", 13 | "import", 14 | "export" 15 | ], 16 | "require": { 17 | "tastyigniter/core": "^v4.0", 18 | "league/csv": "~9.1" 19 | }, 20 | "require-dev": { 21 | "laravel/pint": "^1.2", 22 | "larastan/larastan": "^3.0", 23 | "sampoyigi/testbench": "dev-main as 1.0", 24 | "pestphp/pest-plugin-laravel": "^3.0", 25 | "rector/rector": "^2.0" 26 | }, 27 | "autoload": { 28 | "psr-4": { 29 | "IgniterLabs\\ImportExport\\": "src/" 30 | } 31 | }, 32 | "autoload-dev": { 33 | "psr-4": { 34 | "IgniterLabs\\ImportExport\\Tests\\": "tests/" 35 | } 36 | }, 37 | "extra": { 38 | "tastyigniter-extension": { 39 | "code": "igniterlabs.importexport", 40 | "name": "Import & Export Tool", 41 | "icon": { 42 | "class": "fas fa-file-import", 43 | "backgroundColor": "#147EFB", 44 | "color": "#FFFFFF" 45 | }, 46 | "homepage": "https://tastyigniter.com/marketplace/item/igniterlabs-importexport" 47 | }, 48 | "branch-alias": { 49 | "dev-master": "4.0.x-dev" 50 | } 51 | }, 52 | "minimum-stability": "dev", 53 | "prefer-stable": true, 54 | "config": { 55 | "allow-plugins": { 56 | "pestphp/pest-plugin": true, 57 | "php-http/discovery": true, 58 | "composer/installers": true 59 | }, 60 | "sort-packages": true 61 | }, 62 | "scripts": { 63 | "test:lint": "vendor/bin/pint --test --ansi", 64 | "test:lint-fix": "vendor/bin/pint --ansi", 65 | "test:refactor": "vendor/bin/rector process --dry-run --ansi", 66 | "test:refactor-fix": "vendor/bin/rector process --ansi", 67 | "test:static": "vendor/bin/phpstan analyse --memory-limit=1056M --ansi", 68 | "test:static-fix": "vendor/bin/phpstan --generate-baseline --memory-limit=1056M --ansi", 69 | "test:pest": "vendor/bin/pest", 70 | "test:coverage": "vendor/bin/pest --coverage --exactly=100 --compact", 71 | "test:type-coverage": "vendor/bin/pest --type-coverage --min=100", 72 | "test": [ 73 | "@test:lint", 74 | "@test:refactor", 75 | "@test:static", 76 | "@test:coverage" 77 | ] 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /database/migrations/2025_05_15_131001_create_history_table.php: -------------------------------------------------------------------------------- 1 | engine = 'InnoDB'; 15 | $table->bigIncrements('id'); 16 | $table->unsignedBigInteger('user_id')->nullable(); 17 | $table->string('uuid'); 18 | $table->string('type'); 19 | $table->string('code'); 20 | $table->string('status')->nullable(); 21 | $table->string('error_message')->nullable(); 22 | $table->json('attempted_data')->nullable(); 23 | $table->timestamp('finished_at')->nullable(); 24 | $table->timestamps(); 25 | }); 26 | } 27 | 28 | public function down(): void 29 | { 30 | Schema::dropIfExists('importexport_history'); 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Import/Export" 3 | section: "extensions" 4 | sortOrder: 999 5 | --- 6 | 7 | ## Installation 8 | 9 | You can install the extension via composer using the following command: 10 | 11 | ```bash 12 | composer require igniterlabs/ti-ext-importexport -W 13 | ``` 14 | 15 | Run the database migrations to create the required tables: 16 | 17 | ```bash 18 | php artisan igniter:up 19 | ``` 20 | 21 | ## Getting started 22 | 23 | In the admin area, you can import or export records. Navigate to the _Tools > Import/Export_ admin pages. 24 | 25 | - To import data, you can select the type of data you want to import, choose a file, and then click the import button. The system will process the file and import the data into the database. 26 | - To export data, you can select the type of data you want to export, and then click the export button. The system will generate a CSV file containing the exported data. 27 | - You can also define custom import/export types, see the [Usage](#usage) section below for more details. 28 | 29 | ## Usage 30 | 31 | This section covers how to integrate the Import/Export extension into your own extension if you need to create custom import/export types. The Import/Export extension provides a simple API for managing import and export operations. 32 | 33 | ### Defining import types 34 | 35 | You can define import types by creating an import definition file and a model class that extends `IgniterLabs\ImportExport\Models\ImportModel`. This class should implement the `importData` method to handle the import logic for inserting/updating data into the database. The base class handles file uploads and data processing. 36 | 37 | ```php 38 | use IgniterLabs\ImportExport\Models\ImportModel; 39 | 40 | class MyCustomImport extends ImportModel 41 | { 42 | protected $table = 'db_table_name'; // Specify the database table to import data into 43 | 44 | public function importData(array $data): void 45 | { 46 | // Process the data and insert/update records in the database 47 | foreach ($data as $record) { 48 | try { 49 | // Validate the record before processing 50 | $validated = Validator::validate($record, [ 51 | 'id' => 'required|integer', 52 | 'name' => 'required|string|max:255', 53 | 'description' => 'nullable|string', 54 | ]); 55 | } catch (\Exception $e) { 56 | // Log an error if validation fails 57 | $this->logError($record, $e->getMessage()); 58 | continue; // Skip to the next record 59 | } 60 | 61 | // Example: Insert or update logic 62 | if ($this->update_existing) { 63 | $record = Model::where('id', $record['id'])->first(); 64 | } 65 | 66 | $record ??= new Model(); 67 | 68 | $record->fill($record)->save(); 69 | $record->wasRecentlyCreated ? $this->logCreated() : $this->logUpdated(); 70 | } 71 | } 72 | } 73 | ``` 74 | 75 | Methods like `logCreated`, `logUpdated`, and `logError` can be used to log changes made during the import process. You can pass the `$rowNumber` and `$errorMessage` parameters to the `logError` method to log errors encountered during the import. Both `logCreated` and `logUpdated` methods do not accept any parameters, as they automatically log the creation or update of records. 76 | 77 | Here's an example of an import definition file `customimport.php` that registers the custom import type: 78 | 79 | ```php 80 | return [ 81 | 'columns' => [ 82 | 'id' => 'ID', 83 | 'name' => 'Name', 84 | 'description' => 'Description', 85 | ], 86 | 'fields' => [ 87 | 'update_existing' => [ 88 | 'label' => 'Update existing items', 89 | 'type' => 'switch', 90 | 'default' => true, 91 | ], 92 | ], 93 | ]; 94 | ``` 95 | 96 | This file should be placed in the `resources/models` directory of your extension. The `columns` array defines the columns that will be available for import, and the `fields` array defines any additional fields that can be configured during the import process. 97 | 98 | ### Defining export types 99 | 100 | You can define export types similarly to import types by creating an export definition file and a model class that extends `IgniterLabs\ImportExport\Models\ExportModel`. This class should implement `exportData` method to handle the export logic for fetching your specific data type from the database. The base class handles CSV file generation and download. 101 | 102 | ```php 103 | 104 | use IgniterLabs\ImportExport\Models\ExportModel; 105 | 106 | class MyCustomExport extends ExportModel 107 | { 108 | protected $table = 'db_table_name'; // Specify the database table to export data from 109 | 110 | public $relation[ 111 | // Define any relationships if needed 112 | ]; 113 | 114 | public function exportData(array $columns, array $options = []): array 115 | { 116 | // Define the query to fetch the data to be exported 117 | $query = $this->newQuery(); 118 | 119 | if ($offset = array_get($options, 'offset')) { 120 | $query->offset($offset); 121 | } 122 | 123 | if ($limit = array_get($options, 'limit')) { 124 | $query->limit($limit); 125 | } 126 | 127 | // Fetch the data to be exported 128 | return $query->get()->toArray(); // Return an array of records to be exported 129 | } 130 | } 131 | ``` 132 | 133 | The `$columns` parameter specifies which columns to include in the export. The `$options` parameter specifies additional options for the export, such as `offset` or `limit` for pagination. 134 | 135 | Here's an example of an export definition file `customexport.php` that registers the custom export type: 136 | 137 | ```php 138 | return [ 139 | 'columns' => [ 140 | 'id' => 'ID', 141 | 'name' => 'Name', 142 | 'description' => 'Description', 143 | ], 144 | ]; 145 | ``` 146 | 147 | This file should be placed in the `resources/models` directory of your extension. The `columns` array defines the columns that will be available for export. 148 | 149 | ### Registering import/export types 150 | 151 | You can register your custom import and export types in the `registerImportExportTypes` method of your [Extension class](https://tastyigniter.com/docs/extend/extensions#extension-class). Here is an example: 152 | 153 | ```php 154 | public function registerImportExport(): array 155 | { 156 | return [ 157 | 'import' => [ 158 | 'customimport' => [ 159 | 'label' => 'My Custom Import', 160 | 'model' => \Author\Extension\Models\MyCustomImport::class, 161 | 'configFile' => 'author.extension::/models/customimport', 162 | 'permissions' => 'Author.Extension.ManageImports', 163 | ], 164 | 'export' => [ 165 | 'customexport' => [ 166 | 'label' => 'My Custom Export', 167 | 'model' => \Author\Extension\Models\MyCustomExport::class, 168 | 'configFile' => 'author.extension::/models/customexport', 169 | 'permissions' => 'Author.Extension.ManageExports', 170 | ], 171 | ], 172 | ]; 173 | } 174 | ``` 175 | 176 | This method returns an array of import and export types, where each type is defined by its label, model class, configuration file, and permissions required to access it. 177 | -------------------------------------------------------------------------------- /phpstan-baseline.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | ignoreErrors: 3 | -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | includes: 2 | - ./vendor/larastan/larastan/extension.neon 3 | - phpstan-baseline.neon 4 | 5 | parameters: 6 | level: 5 7 | paths: 8 | - src/ 9 | - resources/ 10 | # ignoreErrors: 11 | # - '#PHPDoc tag @var#' 12 | # excludePaths: 13 | # - ./*/*/FileToBeExcluded.php 14 | # checkMissingIterableValueType: false 15 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | tests 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | ./src 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "method_chaining_indentation": true, 4 | "logical_operators": true, 5 | "simplified_null_return": false, 6 | "cast_spaces": false, 7 | "no_unreachable_default_argument_value": false, 8 | "no_alternative_syntax": false, 9 | "not_operator_with_successor_space": false, 10 | "no_trailing_comma_in_list_call": false, 11 | "phpdoc_summary": false, 12 | "braces": false, 13 | "self_accessor": false, 14 | "phpdoc_separation": false, 15 | "phpdoc_align": false, 16 | "no_trailing_comma_in_singleline": false, 17 | "phpdoc_trim_consecutive_blank_line_separation": true, 18 | "blank_line_before_statement": { 19 | "statements": [ 20 | "declare", 21 | "return", 22 | "throw", 23 | "try" 24 | ] 25 | }, 26 | "single_blank_line_at_eof": false, 27 | "single_space_around_construct": false, 28 | "function_declaration": { 29 | "closure_function_spacing": "none", 30 | "closure_fn_spacing": "none" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | withPaths([ 12 | __DIR__.'/database', 13 | __DIR__.'/src', 14 | __DIR__.'/tests', 15 | ]) 16 | ->withImportNames(removeUnusedImports: true) 17 | ->withRules([ 18 | DeclareStrictTypesRector::class, 19 | ]) 20 | ->withSkip([ 21 | ReturnNeverTypeRector::class, 22 | CatchExceptionNameMatchingTypeRector::class, 23 | ]) 24 | ->withPhpSets(php83: true) 25 | ->withPreparedSets( 26 | deadCode: true, 27 | codeQuality: true, 28 | codingStyle: true, 29 | typeDeclarations: true, 30 | ); 31 | -------------------------------------------------------------------------------- /resources/lang/en/default.php: -------------------------------------------------------------------------------- 1 | 'Import/Export', 7 | 'text_import_export_title' => 'New Import/Export', 8 | 'text_import_title' => 'Import Records', 9 | 'text_export_title' => 'Export Records', 10 | 'text_history_title' => 'Import/Export History', 11 | 'text_tab_title_import_primary' => 'Upload a CSV file', 12 | 'text_tab_title_import_columns' => 'Match columns to import', 13 | 'text_tab_title_import_secondary' => 'Set import options', 14 | 'text_tab_title_export_primary' => 'Export output settings', 15 | 'text_tab_title_export_columns' => 'Columns to export', 16 | 'text_tab_title_export_secondary' => 'Set export options', 17 | 'text_no_import_file' => 'Please upload a valid CSV file.', 18 | 'text_import_row' => 'Row %s - %s', 19 | 'text_import_created' => 'Created (%s)', 20 | 'text_import_updated' => 'Updated (%s)', 21 | 'text_import_skipped' => 'Skipped (%s)', 22 | 'text_import_warnings' => 'Warnings (%s)', 23 | 'text_import_errors' => 'Errors (%s)', 24 | 'text_import_progress' => 'Import progress', 25 | 'text_processing' => 'Processing...', 26 | 'text_uploading' => 'Uploading...', 27 | 28 | 'label_export_record' => 'Choose record to export', 29 | 'label_offset' => 'Offset', 30 | 'label_limit' => 'Limit', 31 | 'label_delimiter' => 'Delimiter Character', 32 | 'label_enclosure' => 'Enclosure Character', 33 | 'label_escape' => 'Escape Character', 34 | 'label_columns' => 'Columns', 35 | 36 | 'label_import_record' => 'Choose record to import', 37 | 'label_import_file' => 'Import file', 38 | 'label_encoding' => 'File encoding', 39 | 'label_import_columns' => 'Import Columns', 40 | 'label_db_columns' => 'Database fields', 41 | 'label_file_columns' => 'File Columns', 42 | 'label_import_ignore' => 'Import/Ignore', 43 | 44 | 'column_history_id' => 'ID', 45 | 'column_history_type' => 'Type', 46 | 'column_history_code' => 'Code', 47 | 'column_history_status' => 'Status', 48 | 'column_history_message' => 'Message', 49 | 'column_history_date' => 'Date', 50 | 51 | 'button_import_records' => 'Import Records', 52 | 'button_export_records' => 'Export Records', 53 | 'button_cancel_import' => 'Cancel Import', 54 | 'button_upload' => 'Upload CSV', 55 | 'button_choose' => 'Choose', 56 | 'button_change' => 'Change', 57 | 'button_download' => 'Download CSV', 58 | 59 | 'error_empty_import_columns' => 'Please specify some columns to import.', 60 | 'error_empty_export_columns' => 'Please specify some columns to export.', 61 | 'error_missing_model' => 'Missing the model property for %s', 62 | 'error_mass_assignment' => "Mass assignment failed for Model attribute ':attribute'.", 63 | 'error_export_not_found' => 'The export was not found', 64 | 'error_file_not_found' => 'The export file was not found', 65 | 'error_invalid_import_file' => 'The import file is invalid. Please upload a valid CSV file.', 66 | 'error_missing_csv_headers' => 'The CSV file has invalid headers. Please upload a valid CSV file.', 67 | 68 | 'alert_export_success' => 'File export process completed! You can download the exported file from the history section.', 69 | 70 | 'help_offset' => 'Number of records to skip before exporting. Use 0 value to start from the first record, 100 to start exporting from the 101th record', 71 | 'help_limit' => 'Number of records to export. If no value is given all records are exported.', 72 | 'help_delimiter' => 'The character used to separate each field', 73 | 'help_enclosure' => 'The character used to enclose each field', 74 | 75 | 'encodings' => [ 76 | 'utf_8' => 'UTF-8', 77 | 'us_ascii' => 'US-ASCII', 78 | 'iso_8859_1' => 'ISO-8859-1 (Latin-1, Western European)', 79 | 'iso_8859_2' => 'ISO-8859-2 (Latin-2, Central European)', 80 | 'iso_8859_3' => 'ISO-8859-3 (Latin-3, South European)', 81 | 'iso_8859_4' => 'ISO-8859-4 (Latin-4, North European)', 82 | 'iso_8859_5' => 'ISO-8859-5 (Latin, Cyrillic)', 83 | 'iso_8859_6' => 'ISO-8859-6 (Latin, Arabic)', 84 | 'iso_8859_7' => 'ISO-8859-7 (Latin, Greek)', 85 | 'iso_8859_8' => 'ISO-8859-8 (Latin, Hebrew)', 86 | 'iso_8859_0' => 'ISO-8859-9 (Latin-5, Turkish)', 87 | 'iso_8859_10' => 'ISO-8859-10 (Latin-6, Nordic)', 88 | 'iso_8859_11' => 'ISO-8859-11 (Latin, Thai)', 89 | 'iso_8859_13' => 'ISO-8859-13 (Latin-7, Baltic Rim)', 90 | 'iso_8859_14' => 'ISO-8859-14 (Latin-8, Celtic)', 91 | 'iso_8859_15' => 'ISO-8859-15 (Latin-9, Western European revision with euro sign)', 92 | 'windows_1251' => 'Windows-1251 (CP1251)', 93 | 'windows_1252' => 'Windows-1252 (CP1252)', 94 | ], 95 | ]; 96 | -------------------------------------------------------------------------------- /resources/models/exportmodel.php: -------------------------------------------------------------------------------- 1 | [ 7 | 'toolbar' => [ 8 | 'buttons' => [ 9 | 'exportRecords' => [ 10 | 'label' => 'lang:igniterlabs.importexport::default.button_export_records', 11 | 'class' => 'btn btn-primary', 12 | 'data-request' => 'onExport', 13 | 'data-progress-indicator' => 'igniterlabs.importexport::default.text_processing', 14 | ], 15 | ], 16 | ], 17 | 'fields' => [ 18 | 'step_primary' => [ 19 | 'label' => 'lang:igniterlabs.importexport::default.text_tab_title_export_primary', 20 | 'type' => 'section', 21 | ], 22 | 'offset' => [ 23 | 'label' => 'lang:igniterlabs.importexport::default.label_offset', 24 | 'type' => 'number', 25 | 'span' => 'left', 26 | 'default' => '0', 27 | 'comment' => 'lang:igniterlabs.importexport::default.help_offset', 28 | ], 29 | 'limit' => [ 30 | 'label' => 'lang:igniterlabs.importexport::default.label_limit', 31 | 'type' => 'number', 32 | 'span' => 'right', 33 | 'comment' => 'lang:igniterlabs.importexport::default.help_limit', 34 | ], 35 | 'delimiter' => [ 36 | 'label' => 'lang:igniterlabs.importexport::default.label_delimiter', 37 | 'type' => 'text', 38 | 'span' => 'left', 39 | 'cssClass' => 'flex-width', 40 | 'default' => ',', 41 | 'comment' => 'lang:igniterlabs.importexport::default.help_delimiter', 42 | ], 43 | 'enclosure' => [ 44 | 'label' => 'lang:igniterlabs.importexport::default.label_enclosure', 45 | 'type' => 'text', 46 | 'span' => 'left', 47 | 'cssClass' => 'flex-width', 48 | 'default' => '"', 49 | 'comment' => 'lang:igniterlabs.importexport::default.help_enclosure', 50 | ], 51 | 'escape' => [ 52 | 'label' => 'lang:igniterlabs.importexport::default.label_escape', 53 | 'type' => 'text', 54 | 'span' => 'left', 55 | 'cssClass' => 'flex-width', 56 | 'default' => '\\', 57 | ], 58 | 'step_columns' => [ 59 | 'label' => 'lang:igniterlabs.importexport::default.text_tab_title_export_columns', 60 | 'type' => 'section', 61 | ], 62 | 'export_columns' => [ 63 | 'label' => 'lang:igniterlabs.importexport::default.label_columns', 64 | 'type' => 'partial', 65 | 'path' => 'igniterlabs.importexport::export_columns', 66 | ], 67 | 'step_secondary' => [ 68 | 'label' => 'lang:igniterlabs.importexport::default.text_tab_title_export_secondary', 69 | 'type' => 'section', 70 | ], 71 | ], 72 | ], 73 | ]; 74 | -------------------------------------------------------------------------------- /resources/models/importmodel.php: -------------------------------------------------------------------------------- 1 | [ 7 | 'toolbar' => [ 8 | 'buttons' => [ 9 | 'importRecords' => [ 10 | 'label' => 'lang:igniterlabs.importexport::default.button_import_records', 11 | 'class' => 'btn btn-primary', 12 | 'data-request' => 'onImport', 13 | 'data-progress-indicator' => 'igniterlabs.importexport::default.text_processing', 14 | ], 15 | 'cancelImport' => [ 16 | 'label' => 'lang:igniterlabs.importexport::default.button_cancel_import', 17 | 'class' => 'btn btn-default', 18 | 'data-request' => 'onDeleteImportFile', 19 | 'data-progress-indicator' => 'igniterlabs.importexport::default.text_processing', 20 | ], 21 | ], 22 | ], 23 | 'fields' => [ 24 | 'step_primary' => [ 25 | 'label' => 'lang:igniterlabs.importexport::default.text_tab_title_import_primary', 26 | 'type' => 'section', 27 | ], 28 | 'encoding' => [ 29 | 'label' => 'lang:igniterlabs.importexport::default.label_encoding', 30 | 'type' => 'select', 31 | 'span' => 'left', 32 | 'cssClass' => 'flex-width', 33 | 'default' => 'utf-8', 34 | 'disabled' => true, 35 | ], 36 | 'delimiter' => [ 37 | 'label' => 'lang:igniterlabs.importexport::default.label_delimiter', 38 | 'type' => 'text', 39 | 'span' => 'left', 40 | 'cssClass' => 'flex-width', 41 | 'default' => ',', 42 | 'comment' => 'lang:igniterlabs.importexport::default.help_delimiter', 43 | ], 44 | 'enclosure' => [ 45 | 'label' => 'lang:igniterlabs.importexport::default.label_enclosure', 46 | 'type' => 'text', 47 | 'span' => 'left', 48 | 'cssClass' => 'flex-width', 49 | 'default' => '"', 50 | 'comment' => 'lang:igniterlabs.importexport::default.help_enclosure', 51 | ], 52 | 'escape' => [ 53 | 'label' => 'lang:igniterlabs.importexport::default.label_escape', 54 | 'type' => 'text', 55 | 'span' => 'left', 56 | 'cssClass' => 'flex-width', 57 | 'default' => '\\', 58 | ], 59 | 'step_columns' => [ 60 | 'label' => 'lang:igniterlabs.importexport::default.text_tab_title_import_columns', 61 | 'type' => 'section', 62 | ], 63 | 'import_columns' => [ 64 | 'label' => 'lang:igniterlabs.importexport::default.label_import_columns', 65 | 'type' => 'partial', 66 | 'cssClass' => 'mb-0', 67 | 'path' => 'igniterlabs.importexport::import_columns', 68 | 'emptyMessage' => 'lang:igniterlabs.importexport::default.text_no_import_file', 69 | ], 70 | ], 71 | ], 72 | ]; 73 | -------------------------------------------------------------------------------- /resources/models/menuexport.php: -------------------------------------------------------------------------------- 1 | [ 5 | 'menu_id' => 'lang:admin::lang.column_id', 6 | 'menu_name' => 'lang:admin::lang.label_name', 7 | 'menu_price' => 'lang:igniter.cart::default.menus.label_price', 8 | 'menu_description' => 'lang:admin::lang.label_description', 9 | 'minimum_qty' => 'lang:igniter.cart::default.menus.label_minimum_qty', 10 | 'categories' => 'lang:igniter.cart::default.menus.label_category', 11 | 'menu_status' => 'lang:admin::lang.label_status', 12 | ], 13 | ]; 14 | -------------------------------------------------------------------------------- /resources/models/menuimport.php: -------------------------------------------------------------------------------- 1 | [ 5 | 'menu_id' => 'lang:admin::lang.column_id', 6 | 'menu_name' => 'lang:admin::lang.label_name', 7 | 'menu_price' => 'lang:igniter.cart::default.menus.label_price', 8 | 'menu_description' => 'lang:admin::lang.label_description', 9 | 'minimum_qty' => 'lang:igniter.cart::default.menus.label_minimum_qty', 10 | 'categories' => 'lang:igniter.cart::default.menus.label_category', 11 | 'menu_status' => 'lang:admin::lang.label_status', 12 | ], 13 | 'fields' => [ 14 | 'update_existing' => [ 15 | 'label' => 'Update existing menu items', 16 | 'type' => 'switch', 17 | 'default' => true, 18 | ], 19 | ], 20 | ]; 21 | -------------------------------------------------------------------------------- /resources/views/_partials/export_columns.blade.php: -------------------------------------------------------------------------------- 1 | @php 2 | $fieldOptions = []; //$field->options(); 3 | $checkedValues = (array)$field->value; 4 | $isScrollable = count($exportColumns) > 10; 5 | @endphp 6 | 7 |
8 | @if ($isScrollable) 9 | 10 | @lang('admin::lang.text_select'): 11 | @lang('admin::lang.text_select_all'), 12 | @lang('admin::lang.text_select_none') 13 | 14 | 15 |
16 |
17 | @endif 18 | 19 | @foreach ($exportColumns as $key => $column) 20 | @php $checkboxId = 'checkbox_'.$field->getId().'_'.$loop->index; @endphp 21 |
22 | 30 | 33 |
34 | @endforeach 35 | 36 | @if ($isScrollable) 37 |
38 |
39 | @endif 40 | 41 |
42 | -------------------------------------------------------------------------------- /resources/views/_partials/export_container.blade.php: -------------------------------------------------------------------------------- 1 |
2 | {!! $exportPrimaryFormWidget->render() !!} 3 | @if ($exportSecondaryFormWidget) 4 | {!! $exportSecondaryFormWidget->render() !!} 5 | @endif 6 |
7 | -------------------------------------------------------------------------------- /resources/views/_partials/export_result.blade.php: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/views/_partials/import_columns.blade.php: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | @if ($importFileColumns) 13 | @foreach ($importFileColumns as $index => $fileColumn) 14 | 15 | 28 | 29 | 40 | 41 | @endforeach 42 | @else 43 | 44 | 45 | 46 | @endif 47 | 48 |
@lang('igniterlabs.importexport::default.label_file_columns')@lang('igniterlabs.importexport::default.label_db_columns')
16 |
17 | 25 | 26 |
27 |
{{ $fileColumn }} 30 | 39 |
@lang('igniterlabs.importexport::default.text_no_import_file')
49 |
50 |
51 | -------------------------------------------------------------------------------- /resources/views/_partials/import_container.blade.php: -------------------------------------------------------------------------------- 1 |
2 | {!! $importPrimaryFormWidget->render() !!} 3 | @if ($importSecondaryFormWidget) 4 | {!! $importSecondaryFormWidget->render() !!} 5 | @endif 6 |
7 | -------------------------------------------------------------------------------- /resources/views/_partials/import_result.blade.php: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
@lang('igniterlabs.importexport::default.text_import_created')
6 |

{{ $importResults->created }}

7 |
8 |
9 |
@lang('igniterlabs.importexport::default.text_import_updated')
10 |

{{ $importResults->updated }}

11 |
12 | @if ($importResults->skippedCount) 13 |
14 |
@lang('igniterlabs.importexport::default.text_import_skipped')
15 |

{{ $importResults->skippedCount }}

16 |
17 | @endif 18 | @if ($importResults->warningCount) 19 |
20 |
@lang('igniterlabs.importexport::default.text_import_warnings')
21 |

{{ $importResults->warningCount }}

22 |
23 | @endif 24 |
25 |
@lang('igniterlabs.importexport::default.text_import_errors')
26 |

{{ $importResults->errorCount }}

27 |
28 |
29 | 30 | @if ($importResults->hasMessages) 31 | @php 32 | $tabs = [ 33 | 'skipped' => lang('igniterlabs.importexport::default.text_import_skipped'), 34 | 'warnings' => lang('igniterlabs.importexport::default.text_import_warnings'), 35 | 'errors' => lang('igniterlabs.importexport::default.text_import_errors'), 36 | ]; 37 | 38 | if (!$importResults->skippedCount) unset($tabs['skipped']); 39 | if (!$importResults->warningCount) unset($tabs['warnings']); 40 | if (!$importResults->errorCount) unset($tabs['errors']); 41 | @endphp 42 |
43 | 56 |
57 | @foreach ($tabs as $code => $tab) 58 |
64 |
    65 | @foreach ($importResults->{$code} as $row => $message) 66 |
  • 67 | {{ sprintf(lang('igniterlabs.importexport::default.text_import_row'), $row + 2) }} 68 | - {{ $message }} 69 |
  • 70 | @endforeach 71 |
72 |
73 | @endforeach 74 |
75 |
76 | @endif 77 |
78 |
79 | -------------------------------------------------------------------------------- /resources/views/_partials/new_export_popup.blade.php: -------------------------------------------------------------------------------- 1 | {!! form_open([ 2 | 'role' => 'form', 3 | 'data-request' => 'onLoadExportForm', 4 | ]) !!} 5 | 9 | 22 | 33 | {!! form_close() !!} 34 | -------------------------------------------------------------------------------- /resources/views/_partials/new_import_popup.blade.php: -------------------------------------------------------------------------------- 1 | {!! form_open([ 2 | 'role' => 'form', 3 | 'data-request' => 'onLoadImportForm', 4 | 'data-request-submit' => 'true', 5 | 'enctype' => 'multipart/form-data', 6 | 'method' => 'POST', 7 | ]) !!} 8 | 12 | 51 | 62 | {!! form_close() !!} 63 | -------------------------------------------------------------------------------- /resources/views/importexport/export.blade.php: -------------------------------------------------------------------------------- 1 |
2 | {!! form_open([ 3 | 'id' => 'form-widget', 4 | 'class' => 'pt-3', 5 | 'role' => 'form', 6 | ]) !!} 7 | 8 | {!! $this->renderExport() !!} 9 | 10 | {!! form_close() !!} 11 |
12 | -------------------------------------------------------------------------------- /resources/views/importexport/import.blade.php: -------------------------------------------------------------------------------- 1 |
2 | {!! form_open([ 3 | 'id' => 'form-widget', 4 | 'class' => 'pt-3', 5 | 'role' => 'form', 6 | ]) !!} 7 | 8 |
9 | {!! $this->renderImport() !!} 10 |
11 | 12 | {!! form_close() !!} 13 |
14 | -------------------------------------------------------------------------------- /resources/views/importexport/index.blade.php: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
@lang('igniterlabs.importexport::default.text_import_export_title')
6 |
7 |
8 | 27 | 46 | 47 |
48 |
49 |
@lang('igniterlabs.importexport::default.text_history_title')
50 |
51 |
52 |
53 |
54 |
55 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | @if ($history) 71 | @foreach ($history as $record) 72 | 73 | 74 | 75 | 76 | 77 | 84 | 85 | 86 | @endforeach 87 | @endif 88 | 89 |
@lang('igniterlabs.importexport::default.column_history_date')@lang('igniterlabs.importexport::default.column_history_type')@lang('igniterlabs.importexport::default.column_history_status')@lang('igniterlabs.importexport::default.column_history_message')@lang('igniterlabs.importexport::default.column_history_id')
{{ day_elapsed($record->created_at) }}{{ $record->label }}{{ ucfirst($record->status) }}{{ html(nl2br($record->error_message)) }} 78 | @if($record->download_url) 79 | @lang('igniterlabs.importexport::default.button_download') 82 | @endif 83 | {{ $record->id }}
90 |
91 |
92 |
93 |
94 |
95 | -------------------------------------------------------------------------------- /src/Classes/ImportExportManager.php: -------------------------------------------------------------------------------- 1 | listImportExportsForType($type), $name, $default); 19 | } 20 | 21 | public function getRecordLabel($type, $name, $default = null) 22 | { 23 | return array_get($this->getRecordConfig($type, $name), 'label', $default); 24 | } 25 | 26 | public function listImportExportsForType($type) 27 | { 28 | return array_get($this->listImportExports(), $type, []); 29 | } 30 | 31 | // 32 | // Registration 33 | // 34 | /** 35 | * Returns a list of the registered import/exports. 36 | */ 37 | public function listImportExports(): array 38 | { 39 | if (!$this->importExportCache) { 40 | $this->loadImportExports(); 41 | } 42 | 43 | return $this->importExportCache; 44 | } 45 | 46 | /** 47 | * Loads registered import/exports from extensions 48 | */ 49 | public function loadImportExports(): void 50 | { 51 | $registeredResources = resolve(ExtensionManager::class)->getRegistrationMethodValues('registerImportExport'); 52 | foreach ($registeredResources as $extensionCode => $records) { 53 | $this->registerImportExports($extensionCode, $records); 54 | } 55 | } 56 | 57 | /** 58 | * Registers the import/exports. 59 | */ 60 | public function registerImportExports(string $extensionCode, array $definitions): void 61 | { 62 | foreach ($definitions as $type => $definition) { 63 | if (!in_array($type, ['import', 'export'])) { 64 | continue; 65 | } 66 | 67 | $this->registerImportExportsForType($type, $extensionCode, $definition); 68 | } 69 | } 70 | 71 | public function registerImportExportsForType($type, string $extensionCode, array $definitions): void 72 | { 73 | $defaultDefinitions = [ 74 | 'label' => null, 75 | 'description' => null, 76 | 'model' => null, 77 | 'configFile' => null, 78 | ]; 79 | 80 | foreach ($definitions as $name => $definition) { 81 | $name = str_replace('.', '-', $extensionCode.'.'.$name); 82 | 83 | $this->importExportCache[$type][$name] = array_merge($defaultDefinitions, $definition); 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Database/Factories/HistoryFactory.php: -------------------------------------------------------------------------------- 1 | 1, 20 | 'uuid' => $this->faker->uuid(), 21 | 'type' => $this->faker->randomElement(['import', 'export']), 22 | 'code' => $this->faker->slug(3), 23 | 'status' => 'pending', 24 | ]; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Extension.php: -------------------------------------------------------------------------------- 1 | [ 26 | 'menus' => [ 27 | 'label' => 'Import Menu Items', 28 | 'model' => MenuImport::class, 29 | 'configFile' => 'igniterlabs.importexport::/models/menuimport', 30 | 'permissions' => ['Admin.Menus'], 31 | ], 32 | ], 33 | 'export' => [ 34 | 'menus' => [ 35 | 'label' => 'Export Menu Items', 36 | 'model' => MenuExport::class, 37 | 'configFile' => 'igniterlabs.importexport::/models/menuexport', 38 | 'permissions' => ['Admin.Menus'], 39 | ], 40 | ], 41 | ]; 42 | } 43 | 44 | #[Override] 45 | public function registerPermissions(): array 46 | { 47 | return [ 48 | 'IgniterLabs.ImportExport.Manage' => [ 49 | 'description' => 'Access import/export tool', 50 | 'group' => 'igniter::system.permissions.name', 51 | ], 52 | ]; 53 | } 54 | 55 | #[Override] 56 | public function registerNavigation(): array 57 | { 58 | return [ 59 | 'tools' => [ 60 | 'child' => [ 61 | 'importexport' => [ 62 | 'priority' => 200, 63 | 'class' => 'importexport', 64 | 'href' => admin_url('igniterlabs/importexport/import_export'), 65 | 'title' => 'Import/Export', 66 | 'permission' => 'IgniterLabs.ImportExport.Manage', 67 | ], 68 | ], 69 | ], 70 | ]; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Http/Actions/ExportController.php: -------------------------------------------------------------------------------- 1 | partialPath[] = '$/'.$classPath; 67 | 68 | parent::__construct($controller); 69 | 70 | // Build configuration 71 | $this->setConfig($controller->exportConfig, $this->requiredConfig); 72 | } 73 | 74 | public function export($context, $recordName = null): ?RedirectResponse 75 | { 76 | $this->loadRecordConfig($context, $recordName); 77 | 78 | if (($redirect = $this->checkPermissions()) instanceof RedirectResponse) { 79 | flash()->error(lang('igniter::admin.alert_user_restricted')); 80 | 81 | return $redirect; 82 | } 83 | 84 | $pageTitle = lang($this->getConfig('record[title]', 'igniterlabs.importexport::default.text_export_title')); 85 | Template::setTitle($pageTitle); 86 | Template::setHeading($pageTitle); 87 | 88 | $this->initExportForms(); 89 | 90 | return null; 91 | } 92 | 93 | public function download($context, $recordName = null, $exportName = null) 94 | { 95 | $this->loadRecordConfig('export', $recordName); 96 | 97 | if (($redirect = $this->checkPermissions()) instanceof RedirectResponse) { 98 | flash()->error(lang('igniter::admin.alert_user_restricted')); 99 | 100 | return $redirect; 101 | } 102 | 103 | try { 104 | // Validate the export name 105 | $this->getExportModel(); 106 | 107 | /** @var History|null $history */ 108 | $history = History::query() 109 | ->where('type', 'export') 110 | ->where('code', $recordName) 111 | ->where('uuid', str_before($exportName, '.csv')) 112 | ->where('status', 'completed') 113 | ->first(); 114 | 115 | throw_unless($history, new FlashException(lang('igniterlabs.importexport::default.error_export_not_found'))); 116 | 117 | $csvPath = $history->getCsvPath(); 118 | 119 | throw_unless(File::exists($csvPath), 120 | new FlashException(lang('igniterlabs.importexport::default.error_file_not_found')), 121 | ); 122 | 123 | return Response::download( 124 | $csvPath, 125 | sprintf('%s-%s.csv', str_after($recordName, 'importexport-'), date('Y-m-d_H-i-s')), 126 | ); 127 | } catch (Throwable $ex) { 128 | flash()->error($ex->getMessage())->important(); 129 | 130 | return $this->controller->refresh(); 131 | } 132 | } 133 | 134 | public function renderExport(): string 135 | { 136 | if (!is_null($this->exportToolbarWidget)) { 137 | $import[] = $this->exportToolbarWidget->render(); 138 | } 139 | 140 | $import[] = $this->importExportMakePartial('export_container'); 141 | 142 | return implode(PHP_EOL, $import); 143 | } 144 | 145 | public function onLoadExportForm() 146 | { 147 | throw_unless(strlen((string)$recordName = post('code')), new FlashException('You must choose a record type to export')); 148 | 149 | $this->loadRecordConfig('export', $recordName); 150 | 151 | throw_unless($this->getConfig('record'), new FlashException($recordName.' is not a registered export template')); 152 | 153 | if (($redirect = $this->checkPermissions()) instanceof RedirectResponse) { 154 | flash()->error(lang('igniter::admin.alert_user_restricted')); 155 | 156 | return $redirect; 157 | } 158 | 159 | return $this->controller->redirect('igniterlabs/importexport/import_export/export/'.$recordName); 160 | } 161 | 162 | public function export_onExport($context, $recordName) 163 | { 164 | $this->loadRecordConfig($context, $recordName); 165 | 166 | throw_if($this->checkPermissions(), new FlashException(lang('igniter::admin.alert_user_restricted'))); 167 | 168 | $validated = request()->validate([ 169 | 'offset' => ['nullable', 'integer'], 170 | 'limit' => ['nullable', 'integer'], 171 | 'delimiter' => ['string'], 172 | 'enclosure' => ['string'], 173 | 'escape' => ['string'], 174 | 'ExportSecondary' => ['nullable', 'array'], 175 | 'export_columns' => ['required', 'array'], 176 | 'export_columns.*' => ['required', 'string'], 177 | ]); 178 | 179 | /** @var History $history */ 180 | $history = History::create([ 181 | 'user_id' => $this->controller->getUser()->getKey(), 182 | 'type' => $context, 183 | 'code' => $recordName, 184 | 'status' => 'pending', 185 | 'attempted_data' => $validated, 186 | ]); 187 | 188 | try { 189 | $exportModel = $this->getExportModel(); 190 | 191 | if ($secondaryData = array_get($validated, 'ExportSecondary')) { 192 | $exportModel->fill($secondaryData); 193 | } 194 | 195 | $exportColumns = $this->processExportColumnsFromRequest($validated); 196 | $exportOptions = array_except($validated, ['ExportSecondary', 'export_columns', 'visible_columns']); 197 | 198 | $csvWriter = $exportModel->export($exportColumns, $exportOptions); 199 | 200 | $csvPath = $history->getCsvPath(); 201 | if (!File::exists($directory = dirname((string)$csvPath))) { 202 | File::makeDirectory($directory, 0755, true); 203 | } 204 | 205 | File::put($csvPath, $csvWriter->toString()); 206 | 207 | $history->markCompleted(); 208 | 209 | flash()->success(lang('igniterlabs.importexport::default.alert_export_success'))->important(); 210 | 211 | return $this->getRedirectUrl(); 212 | } catch (Exception $ex) { 213 | $history->delete(); 214 | 215 | throw $ex; 216 | } 217 | } 218 | 219 | public function getExportModel(): ExportModel 220 | { 221 | return $this->exportModel ??= new ($this->getModelForType('export')); 222 | } 223 | 224 | protected function initExportForms() 225 | { 226 | $exportModel = $this->getExportModel(); 227 | 228 | $this->exportPrimaryFormWidget = $this->makePrimaryFormWidgetForType($exportModel, 'export'); 229 | 230 | $this->exportSecondaryFormWidget = $this->makeSecondaryFormWidgetForType($exportModel, 'export'); 231 | 232 | $stepSectionField = $this->exportPrimaryFormWidget->getField('step_secondary'); 233 | if (!$this->exportSecondaryFormWidget && $stepSectionField) { 234 | $stepSectionField->hidden = true; 235 | } 236 | 237 | $this->prepareExportVars(); 238 | } 239 | 240 | protected function prepareExportVars() 241 | { 242 | $this->vars['recordTitle'] = $this->getConfig('record[title]', 'Unknown Title'); 243 | $this->vars['recordDescription'] = $this->getConfig('record[description]', 'Unknown description'); 244 | $this->vars['exportPrimaryFormWidget'] = $this->exportPrimaryFormWidget; 245 | $this->vars['exportSecondaryFormWidget'] = $this->exportSecondaryFormWidget; 246 | $this->vars['exportColumns'] = $this->getExportColumns(); 247 | 248 | // Make these variables available to widgets 249 | $this->controller->vars += $this->vars; 250 | } 251 | 252 | protected function getExportColumns() 253 | { 254 | if (is_null($this->exportColumns)) { 255 | $configFile = $this->getConfig('record[configFile]'); 256 | $columns = $this->makeListColumns($configFile); 257 | 258 | throw_unless($columns, 259 | new FlashException(lang('igniterlabs.importexport::default.error_empty_export_columns')), 260 | ); 261 | 262 | $this->exportColumns = collect($columns)->map(fn($label): string => lang($label))->all(); 263 | } 264 | 265 | return $this->exportColumns; 266 | } 267 | 268 | protected function processExportColumnsFromRequest(array $request): array 269 | { 270 | $definitions = $this->getExportColumns(); 271 | 272 | return collect(array_get($request, 'export_columns', [])) 273 | ->mapWithKeys(fn($exportColumn) => [$exportColumn => array_get($definitions, $exportColumn, '???')]) 274 | ->all(); 275 | } 276 | 277 | // 278 | // 279 | // 280 | /** 281 | * Called before the form fields are defined. 282 | * 283 | * @param Form $host The hosting form widget 284 | * 285 | * @return void 286 | */ 287 | public function exportFormExtendFieldsBefore($host) {} 288 | 289 | /** 290 | * Called after the form fields are defined. 291 | * 292 | * @param Form $host The hosting form widget 293 | * 294 | * @return void 295 | */ 296 | public function exportFormExtendFields($host, $fields) {} 297 | } 298 | -------------------------------------------------------------------------------- /src/Http/Actions/ImportController.php: -------------------------------------------------------------------------------- 1 | partialPath[] = '$/'.$classPath; 60 | 61 | parent::__construct($controller); 62 | 63 | // Build configuration 64 | $this->setConfig($controller->importConfig, $this->requiredConfig); 65 | } 66 | 67 | public function import(string $context, string $recordName, string $historyUuid) 68 | { 69 | $this->loadRecordConfig($context, $recordName); 70 | 71 | if (!is_null($redirect = $this->checkPermissions())) { 72 | flash()->error(lang('igniter::admin.alert_user_restricted')); 73 | 74 | return $redirect; 75 | } 76 | 77 | /** @var History|null $history */ 78 | $history = History::query()->where('uuid', $historyUuid)->where('code', $recordName)->first(); 79 | if (!$history || !$history->csvExists()) { 80 | flash()->error(lang('igniterlabs.importexport::default.error_invalid_import_file')); 81 | 82 | return $this->getRedirectUrl(); 83 | } 84 | 85 | $pageTitle = lang($this->getConfig('record[title]', 'igniterlabs.importexport::default.text_import_title')); 86 | Template::setTitle($pageTitle); 87 | Template::setHeading($pageTitle); 88 | 89 | $this->initImportForms(); 90 | 91 | $this->prepareImportVars($history); 92 | 93 | return null; 94 | } 95 | 96 | public function renderImport(): string 97 | { 98 | if (!is_null($this->importToolbarWidget)) { 99 | $import[] = $this->importToolbarWidget->render(); 100 | } 101 | 102 | $import[] = $this->importExportMakePartial('import_container'); 103 | 104 | return implode(PHP_EOL, $import); 105 | } 106 | 107 | public function onLoadImportForm() 108 | { 109 | try { 110 | $validated = request()->validate([ 111 | 'code' => ['required', 'string'], 112 | 'import_file' => ['required', 'file', 'mimes:csv,txt'], 113 | ], [ 114 | 'code.required' => 'You must choose a record type to import', 115 | 'import_file.required' => 'You must upload a file to import', 116 | 'import_file.file' => 'You must upload a valid csv file', 117 | 'import_file.mimes' => 'You must upload a valid csv file', 118 | ]); 119 | 120 | $this->loadRecordConfig('import', $recordName = $validated['code']); 121 | 122 | throw_unless($this->getConfig('record'), new FlashException($recordName.' is not a registered import template')); 123 | 124 | if (!is_null($redirect = $this->checkPermissions())) { 125 | flash()->error(lang('igniter::admin.alert_user_restricted')); 126 | 127 | return $redirect; 128 | } 129 | 130 | /** @var History $history */ 131 | $history = History::create([ 132 | 'user_id' => $this->controller->getUser()->getKey(), 133 | 'type' => 'import', 134 | 'code' => $recordName, 135 | 'status' => 'pending', 136 | ]); 137 | 138 | $csvPath = $history->getCsvPath(); 139 | if (!File::exists($directory = dirname((string)$csvPath))) { 140 | File::makeDirectory($directory, 0755, true); 141 | } 142 | 143 | $uploadedFile = request()->file('import_file'); 144 | File::put($csvPath, File::get($uploadedFile->getRealPath())); 145 | 146 | return $this->controller->redirect('igniterlabs/importexport/import_export/import/'.$recordName.'/'.$history->uuid); 147 | } catch (Throwable $ex) { 148 | flash()->error($ex->getMessage())->important(); 149 | 150 | return $this->getRedirectUrl(); 151 | } 152 | } 153 | 154 | public function import_onDeleteImportFile($context, $recordName, $historyUuid) 155 | { 156 | throw_unless( 157 | $history = History::query()->where('uuid', $historyUuid)->where('code', $recordName)->first(), 158 | new FlashException(lang('igniterlabs.importexport::default.error_invalid_import_file')), 159 | ); 160 | 161 | $history->delete(); 162 | 163 | return $this->getRedirectUrl(); 164 | } 165 | 166 | public function import_onImport($context, $recordName, $historyUuid) 167 | { 168 | $this->loadRecordConfig($context, $recordName); 169 | 170 | throw_if($this->checkPermissions(), new FlashException(lang('igniter::admin.alert_user_restricted'))); 171 | 172 | $validated = request()->validate([ 173 | 'delimiter' => ['required', 'string'], 174 | 'enclosure' => ['required', 'string'], 175 | 'escape' => ['required', 'string'], 176 | 'ImportSecondary' => ['nullable', 'array'], 177 | 'match_columns' => ['required', 'array'], 178 | 'match_columns.*' => ['required', 'string'], 179 | 'import_columns' => ['required', 'array'], 180 | 'import_columns.*' => ['required', 'string'], 181 | ]); 182 | 183 | /** @var History|null $history */ 184 | $history = History::query()->where('uuid', $historyUuid)->where('code', $recordName)->first(); 185 | throw_unless($history, new FlashException( 186 | lang('igniterlabs.importexport::default.error_invalid_import_file'), 187 | )); 188 | 189 | $importModel = $this->getImportModel(); 190 | 191 | $history->update(['status' => 'processing', 'attempted_data' => $validated]); 192 | 193 | if ($optionData = array_get($validated, 'ImportSecondary')) { 194 | $importModel->fill($optionData); 195 | } 196 | 197 | throw_unless( 198 | $importColumns = $this->processImportColumnsFromRequest($validated), 199 | new FlashException(lang('igniterlabs.importexport::default.error_empty_import_columns')), 200 | ); 201 | 202 | $importOptions = array_except($validated, ['ImportSecondary', 'match_columns', 'import_columns']); 203 | $importModel->import($importColumns, $importOptions, $history->getCsvPath()); 204 | 205 | $history->markCompleted([ 206 | 'error_message' => $this->buildImportResultMessage($importModel->getResultStats()), 207 | ]); 208 | 209 | File::delete($history->getCsvPath()); 210 | 211 | return $this->getRedirectUrl(); 212 | } 213 | 214 | public function getImportModel(): ImportModel 215 | { 216 | return $this->importModel ??= new ($this->getModelForType('import')); 217 | } 218 | 219 | protected function initImportForms() 220 | { 221 | $importModel = $this->getImportModel(); 222 | 223 | $this->importPrimaryFormWidget = $this->makePrimaryFormWidgetForType($importModel, 'import'); 224 | 225 | $this->importSecondaryFormWidget = $this->makeSecondaryFormWidgetForType($importModel, 'import'); 226 | } 227 | 228 | protected function prepareImportVars(History $history) 229 | { 230 | $this->vars['recordTitle'] = $this->getConfig('record[title]', 'Unknown Title'); 231 | $this->vars['recordDescription'] = $this->getConfig('record[description]', 'Unknown description'); 232 | $this->vars['importPrimaryFormWidget'] = $this->importPrimaryFormWidget; 233 | $this->vars['importSecondaryFormWidget'] = $this->importSecondaryFormWidget; 234 | $this->vars['importColumns'] = $importColumns = $this->getImportColumns(); 235 | $this->vars['importFileUuid'] = $history->uuid; 236 | $this->vars['importFileColumns'] = $this->getImportFileColumns($history, $importColumns); 237 | 238 | // Make these variables available to widgets 239 | $this->controller->vars += $this->vars; 240 | } 241 | 242 | protected function getImportColumns() 243 | { 244 | if (is_null($this->importColumns)) { 245 | $configFile = $this->getConfig('record[configFile]'); 246 | $columns = $this->makeListColumns($configFile); 247 | 248 | throw_unless($columns, 249 | new FlashException(lang('igniterlabs.importexport::default.error_empty_import_columns')), 250 | ); 251 | 252 | $this->importColumns = collect($columns)->map(fn($label): string => lang($label))->all(); 253 | } 254 | 255 | return $this->importColumns; 256 | } 257 | 258 | protected function getImportFileColumns(History $history, array $importColumns): array 259 | { 260 | $reader = CsvReader::createFromPath($history->getCsvPath(), 'r'); 261 | $firstRow = $reader->nth(0); 262 | 263 | return array_diff($firstRow, array_values($importColumns)) === [] 264 | ? $firstRow 265 | : throw new FlashException(lang('igniterlabs.importexport::default.error_missing_csv_headers')); 266 | } 267 | 268 | protected function processImportColumnsFromRequest(array $request): array 269 | { 270 | $definitions = $this->getImportColumns(); 271 | $dbColumns = array_filter(array_get($request, 'import_columns', [])); 272 | 273 | return collect(array_get($request, 'match_columns', [])) 274 | ->filter(fn($fileColumn): bool => in_array($fileColumn, $definitions)) 275 | ->mapWithKeys(fn($fileColumn, $index) => [$index => [array_get($dbColumns, $index), $fileColumn]]) 276 | ->filter() 277 | ->all(); 278 | } 279 | 280 | // 281 | // 282 | // 283 | /** 284 | * Called before the form fields are defined. 285 | * 286 | * @param Form $host The hosting form widget 287 | * 288 | * @return void 289 | */ 290 | public function importFormExtendFieldsBefore($host) {} 291 | 292 | /** 293 | * Called after the form fields are defined. 294 | * 295 | * @param Form $host The hosting form widget 296 | * 297 | * @return void 298 | */ 299 | public function importFormExtendFields($host, $fields) {} 300 | 301 | protected function buildImportResultMessage(array $importResults): string 302 | { 303 | $importResults = (object)$importResults; 304 | 305 | $resultMessage = implode(PHP_EOL, array_filter([ 306 | sprintf(lang('igniterlabs.importexport::default.text_import_created'), $importResults->created), 307 | sprintf(lang('igniterlabs.importexport::default.text_import_updated'), $importResults->updated), 308 | sprintf(lang('igniterlabs.importexport::default.text_import_errors'), $importResults->errorCount), 309 | ])); 310 | 311 | if ($importResults->errorCount) { 312 | $resultMessage .= PHP_EOL; 313 | $resultMessage .= collect($importResults->errors)->map(fn($message, $row): string => sprintf(lang('igniterlabs.importexport::default.text_import_row'), $row, $message))->implode(PHP_EOL); 314 | } 315 | 316 | return $resultMessage; 317 | } 318 | } 319 | -------------------------------------------------------------------------------- /src/Http/Controllers/ImportExport.php: -------------------------------------------------------------------------------- 1 | 'Import Records', 25 | 'configFile' => 'importmodel', 26 | 'redirect' => 'igniterlabs/importexport/import_export', 27 | ]; 28 | 29 | public $exportConfig = [ 30 | 'title' => 'Export Records', 31 | 'configFile' => 'exportmodel', 32 | 'redirect' => 'igniterlabs/importexport/import_export', 33 | 'back' => 'igniterlabs/importexport/import_export', 34 | ]; 35 | 36 | protected null|string|array $requiredPermissions = 'IgniterLabs.ImportExport.Manage'; 37 | 38 | public function __construct() 39 | { 40 | parent::__construct(); 41 | 42 | AdminMenu::setContext('importexport', 'tools'); 43 | } 44 | 45 | public function index(): void 46 | { 47 | $pageTitle = lang('igniterlabs.importexport::default.text_index_title'); 48 | Template::setTitle($pageTitle); 49 | Template::setHeading($pageTitle); 50 | 51 | $this->vars['history'] = History::query() 52 | ->orderBy('updated_at', 'desc') 53 | ->paginate(25, ['*'], 'page', input('page')); 54 | } 55 | 56 | public function index_onLoadPopup(): array 57 | { 58 | $context = post('context'); 59 | throw_if(!in_array($context, ['import', 'export']), new FlashException('Invalid type specified')); 60 | 61 | $this->vars['context'] = $context; 62 | $this->vars['importExports'] = resolve(ImportExportManager::class)->listImportExportsForType($context); 63 | 64 | return [ 65 | '#importExportModalContent' => $this->makePartial($context === 'export' ? 'new_export_popup' : 'new_import_popup'), 66 | ]; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Models/ExportModel.php: -------------------------------------------------------------------------------- 1 | 'Column label', 22 | * 'db_column_name2' => 'Another label', 23 | * ], 24 | * [...] 25 | */ 26 | abstract public function exportData(array $columns, array $options = []): array; 27 | 28 | /** 29 | * Export data based on column names and labels. 30 | * The $columns array should be in the format of: 31 | */ 32 | public function export($columns, $options): CsvWriter 33 | { 34 | $data = $this->exportData(array_keys($columns), $options); 35 | 36 | return $this->processExportData($columns, $data, $options); 37 | } 38 | 39 | /** 40 | * Converts a data collection to a CSV file. 41 | */ 42 | protected function processExportData($columns, $results, $options): CsvWriter 43 | { 44 | $columns = $this->exportExtendColumns($columns); 45 | 46 | return $this->prepareCsvWriter($options, $columns, $results); 47 | } 48 | 49 | /** 50 | * Used to override column definitions at export time. 51 | * @return array 52 | */ 53 | protected function exportExtendColumns($columns) 54 | { 55 | return $columns; 56 | } 57 | 58 | protected function prepareCsvWriter($options, $columns, $results): CsvWriter 59 | { 60 | $options = array_merge([ 61 | 'delimiter' => null, 62 | 'enclosure' => null, 63 | 'escape' => null, 64 | ], $options); 65 | 66 | $csvWriter = CsvWriter::createFromFileObject(new SplTempFileObject); 67 | 68 | $csvWriter->setOutputBOM(CsvWriter::BOM_UTF8); 69 | 70 | if (!is_null($options['delimiter'])) { 71 | $csvWriter->setDelimiter($options['delimiter']); 72 | } 73 | 74 | if (!is_null($options['enclosure'])) { 75 | $csvWriter->setEnclosure($options['enclosure']); 76 | } 77 | 78 | if (!is_null($options['escape'])) { 79 | $csvWriter->setEscape($options['escape']); 80 | } 81 | 82 | // Insert headers 83 | $csvWriter->insertOne($columns); 84 | 85 | // Insert records 86 | foreach ($results as $result) { 87 | $csvWriter->insertOne($this->processExportRow($columns, $result)); 88 | } 89 | 90 | return $csvWriter; 91 | } 92 | 93 | protected function processExportRow($columns, $record): array 94 | { 95 | $results = []; 96 | foreach ($columns as $column => $label) { 97 | $results[] = array_get($record, $column); 98 | } 99 | 100 | return $results; 101 | } 102 | 103 | /** 104 | * Implodes a single dimension array using pipes (|) 105 | * Multi dimensional arrays are not allowed. 106 | */ 107 | protected function encodeArrayValue($data, string $delimiter = '|'): string 108 | { 109 | $newData = []; 110 | foreach ($data as $value) { 111 | $newData[] = is_array($value) ? 'Array' : str_replace($delimiter, '\\'.$delimiter, $value); 112 | } 113 | 114 | return implode($delimiter, $newData); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/Models/History.php: -------------------------------------------------------------------------------- 1 | [ 38 | 'user' => [User::class, 'foreignKey' => 'user_id'], 39 | ], 40 | ]; 41 | 42 | public $casts = [ 43 | 'attempted_data' => 'json', 44 | ]; 45 | 46 | public function markCompleted(array $attributes = []) 47 | { 48 | return $this->update(array_merge(['status' => 'completed'], $attributes)); 49 | } 50 | 51 | protected function beforeCreate() 52 | { 53 | $this->uuid = (string)Str::uuid(); 54 | } 55 | 56 | protected function beforeDelete() 57 | { 58 | $csvPath = $this->getCsvPath(); 59 | if (File::exists($csvPath)) { 60 | File::delete($csvPath); 61 | } 62 | } 63 | 64 | public function getLabelAttribute() 65 | { 66 | return resolve(ImportExportManager::class)->getRecordLabel($this->type, $this->code); 67 | } 68 | 69 | public function getDownloadUrlAttribute(): ?string 70 | { 71 | if (!$this->exists || $this->type !== 'export' || $this->status !== 'completed') { 72 | return null; 73 | } 74 | 75 | return admin_url(sprintf('igniterlabs/importexport/import_export/download/%s/%s', $this->code, $this->uuid)); 76 | } 77 | 78 | public function getCsvPath(): string 79 | { 80 | return temp_path(sprintf('importexport/%s.csv', $this->uuid)); 81 | } 82 | 83 | public function csvExists(): bool 84 | { 85 | return File::exists($this->getCsvPath()); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Models/ImportModel.php: -------------------------------------------------------------------------------- 1 | 0, 23 | 'created' => 0, 24 | 'errorCount' => 0, 25 | 'errors' => [], 26 | ]; 27 | 28 | /** 29 | * Called when data has being imported. 30 | * The $results array should be in the format of: 31 | * 32 | * [ 33 | * 'db_name1' => 'Some value', 34 | * 'db_name2' => 'Another value' 35 | * ], 36 | * [...] 37 | */ 38 | abstract public function importData(array $results): void; 39 | 40 | /** 41 | * Import data based on column names matching header indexes in the CSV. 42 | */ 43 | public function import($columns, $options = [], ?string $importCsvFile = null): void 44 | { 45 | $data = $this->processImportData($importCsvFile, $columns, $options); 46 | 47 | $this->importData($data); 48 | } 49 | 50 | /** 51 | * Converts column index to database column map to an array containing 52 | * database column names and values pulled from the CSV file. 53 | */ 54 | protected function processImportData(string $filePath, $columns, $options): array 55 | { 56 | $csvReader = $this->prepareCsvReader($options, $filePath); 57 | 58 | $result = []; 59 | $csvStatement = new CsvStatement; 60 | $contents = $csvStatement->process($csvReader); 61 | foreach ($contents as $row) { 62 | $result[] = $this->processImportRow($row, $columns); 63 | } 64 | 65 | return $result; 66 | } 67 | 68 | protected function prepareCsvReader($options, string $filePath): CsvReader 69 | { 70 | $defaultOptions = [ 71 | 'delimiter' => null, 72 | 'enclosure' => null, 73 | 'escape' => null, 74 | ]; 75 | 76 | $options = array_merge($defaultOptions, $options); 77 | 78 | $csvReader = CsvReader::createFromPath($filePath, 'r+'); 79 | 80 | if (!is_null($options['delimiter'])) { 81 | $csvReader->setDelimiter($options['delimiter']); 82 | } 83 | 84 | if (!is_null($options['enclosure'])) { 85 | $csvReader->setEnclosure($options['enclosure']); 86 | } 87 | 88 | if (!is_null($options['escape'])) { 89 | $csvReader->setEscape($options['escape']); 90 | } 91 | 92 | $csvReader->setHeaderOffset(0); 93 | 94 | return $csvReader; 95 | } 96 | 97 | /** 98 | * Converts a single row of CSV data to the column map. 99 | */ 100 | protected function processImportRow($rowData, $columns): array 101 | { 102 | $newRow = []; 103 | 104 | foreach ($columns as [$dbName, $fileColumn]) { 105 | $newRow[$dbName] = array_get($rowData, $fileColumn); 106 | } 107 | 108 | return $newRow; 109 | } 110 | 111 | protected function decodeArrayValue($value, string $delimiter = '|'): array 112 | { 113 | if (!str_contains((string)$value, $delimiter)) { 114 | return [$value]; 115 | } 116 | 117 | $data = preg_split('~(? lang('igniterlabs.importexport::default.encodings.'.str_slug($option, '_')), $options); 151 | 152 | return array_combine($options, $translated); 153 | } 154 | 155 | // 156 | // Result logging 157 | // 158 | public function getResultStats(): array 159 | { 160 | return $this->resultStats; 161 | } 162 | 163 | protected function logUpdated(): void 164 | { 165 | $this->resultStats['updated']++; 166 | } 167 | 168 | protected function logCreated(): void 169 | { 170 | $this->resultStats['created']++; 171 | } 172 | 173 | protected function logError($rowIndex, $message): void 174 | { 175 | $this->resultStats['errorCount']++; 176 | $this->resultStats['errors'][$rowIndex] = $message; 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/Models/MenuExport.php: -------------------------------------------------------------------------------- 1 | [ 34 | 'menu_categories' => [Category::class, 'table' => 'menu_categories', 'foreignKey' => 'menu_id'], 35 | ], 36 | ]; 37 | 38 | protected $appends = [ 39 | 'categories', 40 | ]; 41 | 42 | #[Override] 43 | public function exportData(array $columns, array $options = []): array 44 | { 45 | $query = self::make()->with([ 46 | 'menu_categories', 47 | ]); 48 | 49 | if ($offset = array_get($options, 'offset')) { 50 | $query->offset($offset); 51 | } 52 | 53 | if ($limit = array_get($options, 'limit')) { 54 | $query->limit($limit); 55 | } 56 | 57 | return $query->get()->toArray(); 58 | } 59 | 60 | public function getCategoriesAttribute(): string 61 | { 62 | return $this->encodeArrayValue($this->menu_categories?->pluck('name')->all() ?? []); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Models/MenuImport.php: -------------------------------------------------------------------------------- 1 | $data) { 41 | try { 42 | $validated = Validator::validate($data, [ 43 | 'menu_id' => 'nullable|integer', 44 | 'menu_name' => 'required|string|between:2,255', 45 | 'menu_description' => 'nullable|string|between:2,1028', 46 | 'menu_price' => 'nullable|numeric|min:0', 47 | 'categories' => 'nullable|string|between:2,1028', 48 | 'minimum_qty' => 'nullable|integer|min:1', 49 | 'menu_status' => 'boolean', 50 | ]); 51 | 52 | $menuItem = new Menu; 53 | if ($this->update_existing) { 54 | $menuItem = $this->findDuplicateMenuItem($validated) ?: $menuItem; 55 | } 56 | 57 | $except = ['menu_id', 'categories']; 58 | foreach (array_except($validated, $except) as $attribute => $value) { 59 | $menuItem->{$attribute} = $value ?: null; 60 | } 61 | 62 | $menuItem->save(); 63 | $menuItem->wasRecentlyCreated ? $this->logCreated() : $this->logUpdated(); 64 | 65 | $encodedCategoryNames = array_get($validated, 'categories'); 66 | if ($encodedCategoryNames && ($categoryIds = $this->getCategoryIdsForMenuItem($encodedCategoryNames))) { 67 | $menuItem->categories()->sync($categoryIds, false); 68 | } 69 | } catch (Exception $ex) { 70 | $this->logError($row, $ex->getMessage()); 71 | } 72 | } 73 | } 74 | 75 | protected function findDuplicateMenuItem($data) 76 | { 77 | if ($id = array_get($data, 'menu_id')) { 78 | return Menu::find($id); 79 | } 80 | 81 | return Menu::query()->firstWhere('menu_name', array_get($data, 'menu_name')); 82 | } 83 | 84 | protected function getCategoryIdsForMenuItem($encodedCategoryNames): array 85 | { 86 | $ids = []; 87 | $categoryNames = $this->decodeArrayValue($encodedCategoryNames); 88 | foreach ($categoryNames as $name) { 89 | if (strlen($name = trim((string)$name)) < 1) { 90 | continue; 91 | } 92 | 93 | if (isset($this->categoryNameCache[$name])) { 94 | $ids[] = $this->categoryNameCache[$name]; 95 | } else { 96 | /** @var Category $category */ 97 | $category = Category::query()->firstOrCreate(['name' => $name]); 98 | $category->wasRecentlyCreated ? $this->logCreated() : $this->logUpdated(); 99 | 100 | $ids[] = $this->categoryNameCache[$name] = $category->category_id; 101 | } 102 | } 103 | 104 | return $ids; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/Traits/ImportExportHelper.php: -------------------------------------------------------------------------------- 1 | controller->makePartial($partial, $params + $this->vars, false); 21 | } 22 | 23 | protected function getModelForType($type): ImportModel|ExportModel 24 | { 25 | if (!$modelClass = $this->getConfig('record[model]')) { 26 | throw new FlashException(sprintf(lang('igniterlabs.importexport::default.error_missing_model'), $type)); 27 | } 28 | 29 | return new $modelClass; 30 | } 31 | 32 | protected function makeListColumns($configFile): array 33 | { 34 | return $this->loadConfig($configFile, [], 'columns') ?? []; 35 | } 36 | 37 | protected function checkPermissions(): ?RedirectResponse 38 | { 39 | $permissions = (array)$this->getConfig('record[permissions]'); 40 | 41 | return $permissions && !AdminAuth::getUser()->hasPermission($permissions) 42 | ? $this->controller->redirect('igniterlabs/importexport/import_export') 43 | : null; 44 | } 45 | 46 | protected function getRedirectUrl() 47 | { 48 | $redirect = $this->getConfig('redirect'); 49 | 50 | return $redirect ? $this->controller->redirect($redirect) : $this->controller->refresh(); 51 | } 52 | 53 | protected function loadRecordConfig($type, $recordName) 54 | { 55 | $config = $this->getConfig(); 56 | $config['record'] = resolve(ImportExportManager::class)->getRecordConfig($type, $recordName); 57 | 58 | $this->setConfig($config); 59 | } 60 | 61 | protected function makePrimaryFormWidgetForType($model, string $type) 62 | { 63 | $configFile = $this->getConfig('configFile'); 64 | $modelConfig = $this->loadConfig($configFile, ['form'], 'form'); 65 | $widgetConfig = array_except($modelConfig, 'toolbar'); 66 | $widgetConfig['model'] = $model; 67 | $widgetConfig['alias'] = $type.'PrimaryForm'; 68 | $widgetConfig['cssClass'] = $type.'-primary-form'; 69 | 70 | $widget = $this->makeWidget(Form::class, $widgetConfig); 71 | 72 | $widget->bindEvent('form.extendFieldsBefore', function() use ($type, $widget): void { 73 | $this->controller->{$type.'FormExtendFieldsBefore'}($widget); 74 | }); 75 | 76 | $widget->bindEvent('form.extendFields', function($fields) use ($type, $widget): void { 77 | $this->controller->{$type.'FormExtendFields'}($widget, $fields); 78 | }); 79 | 80 | $widget->bindToController(); 81 | 82 | if (isset($modelConfig['toolbar']) && isset($this->controller->widgets['toolbar'])) { 83 | $toolbarWidget = $this->controller->widgets['toolbar']; 84 | if ($toolbarWidget instanceof Toolbar) { 85 | $toolbarWidget->addButtons(array_get($modelConfig['toolbar'], 'buttons', [])); 86 | } 87 | 88 | $this->{$type.'ToolbarWidget'} = $toolbarWidget; 89 | } 90 | 91 | return $widget; 92 | } 93 | 94 | protected function makeSecondaryFormWidgetForType($model, string $type) 95 | { 96 | if ( 97 | (!$configFile = $this->getConfig('record[configFile]')) || 98 | (!$fields = $this->loadConfig($configFile, [], 'fields')) 99 | ) { 100 | return null; 101 | } 102 | 103 | $widgetConfig['fields'] = $fields; 104 | $widgetConfig['model'] = $model; 105 | $widgetConfig['alias'] = $type.'SecondaryForm'; 106 | $widgetConfig['arrayName'] = ucfirst($type).'Secondary'; 107 | $widgetConfig['cssClass'] = $type.'-secondary-form'; 108 | 109 | $widget = $this->makeWidget(Form::class, $widgetConfig); 110 | 111 | $widget->bindToController(); 112 | 113 | return $widget; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /tests/Classes/ImportExportManagerTest.php: -------------------------------------------------------------------------------- 1 | extensionManagerMock = Mockery::mock(ExtensionManager::class); 10 | app()->instance(ExtensionManager::class, $this->extensionManagerMock); 11 | $this->importExportManager = new ImportExportManager; 12 | }); 13 | 14 | it('can get record config', function(): void { 15 | $type = 'import'; 16 | $name = 'test-config'; 17 | $mockData = [$type => [$name => 'config-value']]; 18 | 19 | $importConfig = [ 20 | 'label' => 'Import Items', 21 | 'description' => 'Test import items', 22 | 'model' => TestImport::class, 23 | 'configFile' => __DIR__.'/../_fixtures/testimport', 24 | ]; 25 | $exportConfig = [ 26 | 'label' => 'Export Items', 27 | 'description' => 'Test export items', 28 | 'model' => TestExport::class, 29 | 'configFile' => __DIR__.'/../_fixtures/testexport', 30 | ]; 31 | 32 | $this->extensionManagerMock 33 | ->shouldReceive('getRegistrationMethodValues') 34 | ->with('registerImportExport') 35 | ->andReturn([ 36 | 'test.extension' => [ 37 | 'import' => [ 38 | 'test-record' => $importConfig, 39 | ], 40 | 'export' => [ 41 | 'test-record' => $exportConfig, 42 | ], 43 | 'invalid-type' => [ 44 | 'test-record' => [], 45 | ], 46 | ], 47 | ]); 48 | 49 | expect($this->importExportManager->getRecordConfig('import', 'test-extension-test-record'))->toBe($importConfig) 50 | ->and($this->importExportManager->getRecordConfig('export', 'test-extension-test-record'))->toBe($exportConfig) 51 | ->and($this->importExportManager->listImportExportsForType('export'))->toBeArray() 52 | ->and($this->importExportManager->listImportExportsForType('invalid-type'))->toBe([]); 53 | }); 54 | -------------------------------------------------------------------------------- /tests/ExtensionTest.php: -------------------------------------------------------------------------------- 1 | register(); 14 | 15 | expect(app()->bound(ImportExportManager::class))->toBeTrue(); 16 | }); 17 | 18 | it('registers import & export record types', function(): void { 19 | $definitions = (new Extension(app()))->registerImportExport(); 20 | 21 | expect($definitions['import']['menus'])->toHaveKey('model', MenuImport::class) 22 | ->and($definitions['import']['menus'])->toHaveKey('configFile', 'igniterlabs.importexport::/models/menuimport') 23 | ->and($definitions['import']['menus'])->toHaveKey('permissions', ['Admin.Menus']) 24 | ->and($definitions['export']['menus'])->toHaveKey('model', MenuExport::class) 25 | ->and($definitions['export']['menus'])->toHaveKey('configFile', 'igniterlabs.importexport::/models/menuexport') 26 | ->and($definitions['export']['menus'])->toHaveKey('permissions', ['Admin.Menus']); 27 | }); 28 | 29 | it('registers permissions', function(): void { 30 | $extension = new Extension(app()); 31 | $permissions = $extension->registerPermissions(); 32 | 33 | expect($permissions) 34 | ->toHaveKey('IgniterLabs.ImportExport.Manage', [ 35 | 'description' => 'Access import/export tool', 36 | 'group' => 'igniter::system.permissions.name', 37 | ]); 38 | }); 39 | 40 | it('registers admin navigation menu', function(): void { 41 | $extension = new Extension(app()); 42 | $navMenus = $extension->registerNavigation(); 43 | 44 | expect($navMenus['tools']['child']) 45 | ->toHaveKey('importexport', [ 46 | 'priority' => 200, 47 | 'class' => 'importexport', 48 | 'href' => admin_url('igniterlabs/importexport/import_export'), 49 | 'title' => 'Import/Export', 50 | 'permission' => 'IgniterLabs.ImportExport.Manage', 51 | ]); 52 | }); 53 | -------------------------------------------------------------------------------- /tests/Http/Actions/ExportControllerTest.php: -------------------------------------------------------------------------------- 1 | post(route('igniterlabs.importexport.import_export'), [ 17 | 'code' => 'igniterlabs-importexport-menus', 18 | ], [ 19 | 'X-Requested-With' => 'XMLHttpRequest', 20 | 'X-IGNITER-REQUEST-HANDLER' => 'onLoadExportForm', 21 | ]) 22 | ->assertJsonFragment([ 23 | 'X_IGNITER_REDIRECT' => admin_url('igniterlabs/importexport/import_export/export/igniterlabs-importexport-menus'), 24 | ]); 25 | }); 26 | 27 | it('throws exception when loading export form with incorrect code', function(): void { 28 | actingAsSuperUser() 29 | ->post(route('igniterlabs.importexport.import_export'), [ 30 | 'code' => 'invalid-code', 31 | ], [ 32 | 'X-Requested-With' => 'XMLHttpRequest', 33 | 'X-IGNITER-REQUEST-HANDLER' => 'onLoadExportForm', 34 | ]) 35 | ->assertSee('invalid-code is not a registered export template'); 36 | }); 37 | 38 | it('flashes validation error when loading export form with restricted access', function(): void { 39 | $userRole = UserRole::factory()->create([ 40 | 'permissions' => ['IgniterLabs.ImportExport.Manage' => 1], 41 | ]); 42 | $user = User::factory()->for($userRole, 'role')->create(); 43 | 44 | $this 45 | ->actingAs($user, 'igniter-admin') 46 | ->post(route('igniterlabs.importexport.import_export'), [ 47 | 'code' => 'igniterlabs-importexport-menus', 48 | ], [ 49 | 'X-Requested-With' => 'XMLHttpRequest', 50 | 'X-IGNITER-REQUEST-HANDLER' => 'onLoadExportForm', 51 | ]) 52 | ->assertJsonFragment([ 53 | 'X_IGNITER_REDIRECT' => admin_url('igniterlabs/importexport/import_export'), 54 | ]); 55 | 56 | expect(flash()->messages()->first())->message->toBe(lang('igniter::admin.alert_user_restricted')); 57 | }); 58 | 59 | it('loads export records form page', function(): void { 60 | actingAsSuperUser() 61 | ->get(route('igniterlabs.importexport.import_export', ['slug' => 'export/igniterlabs-importexport-menus'])) 62 | ->assertOk(); 63 | }); 64 | 65 | it('does not load export records form page when user has restricted access', function(): void { 66 | $userRole = UserRole::factory()->create([ 67 | 'permissions' => ['IgniterLabs.ImportExport.Manage' => 1], 68 | ]); 69 | 70 | $this 71 | ->actingAs(User::factory()->for($userRole, 'role')->create(), 'igniter-admin') 72 | ->get(route('igniterlabs.importexport.import_export', ['slug' => 'export/igniterlabs-importexport-menus'])) 73 | ->assertRedirectContains('igniterlabs/importexport/import_export'); 74 | 75 | expect(flash()->messages()->first())->message->toBe(lang('igniter::admin.alert_user_restricted')); 76 | }); 77 | 78 | it('throws exception when registered export type columns is missing', function(): void { 79 | app()->instance(ImportExportManager::class, $importExportManagerMock = mock(ImportExportManager::class)); 80 | $importExportManagerMock->shouldReceive('getRecordConfig') 81 | ->once() 82 | ->with('export', 'type-with-missing-columns') 83 | ->andReturn([ 84 | 'label' => 'Custom Export', 85 | 'description' => 'Custom export description', 86 | 'model' => MenuExport::class, 87 | 'configFile' => [], 88 | ]); 89 | 90 | actingAsSuperUser() 91 | ->post(route('igniterlabs.importexport.import_export', ['slug' => 'export/type-with-missing-columns']), [ 92 | 'offset' => '0', 93 | 'limit' => '', 94 | 'delimiter' => ',', 95 | 'enclosure' => '"', 96 | 'escape' => '\\', 97 | 'export_columns' => [ 98 | 'menu_id', 99 | 'menu_name', 100 | 'menu_price', 101 | 'menu_description', 102 | 'minimum_qty', 103 | 'categories', 104 | 'menu_status', 105 | ], 106 | ], [ 107 | 'X-Requested-With' => 'XMLHttpRequest', 108 | 'X-IGNITER-REQUEST-HANDLER' => 'onExport', 109 | ]) 110 | ->assertSee(lang('igniterlabs.importexport::default.error_empty_export_columns')); 111 | }); 112 | 113 | it('processes export', function(): void { 114 | File::deleteDirectory(dirname((new History)->getCsvPath())); 115 | 116 | actingAsSuperUser() 117 | ->post(route('igniterlabs.importexport.import_export', ['slug' => 'export/igniterlabs-importexport-menus']), [ 118 | 'offset' => '2', 119 | 'limit' => '10', 120 | 'delimiter' => ',', 121 | 'enclosure' => '"', 122 | 'escape' => '\\', 123 | 'ExportSecondary' => [ 124 | 'skip' => '0', 125 | ], 126 | 'export_columns' => [ 127 | 'menu_id', 128 | 'menu_name', 129 | 'menu_price', 130 | 'menu_description', 131 | 'minimum_qty', 132 | 'categories', 133 | 'menu_status', 134 | ], 135 | ], [ 136 | 'X-Requested-With' => 'XMLHttpRequest', 137 | 'X-IGNITER-REQUEST-HANDLER' => 'onExport', 138 | ]); 139 | 140 | $history = History::query() 141 | ->where('type', 'export') 142 | ->where('code', 'igniterlabs-importexport-menus') 143 | ->where('status', 'completed') 144 | ->first(); 145 | 146 | expect($history)->not->toBeNull() 147 | ->and(flash()->messages()->first())->message->toBe(lang('igniterlabs.importexport::default.alert_export_success')); 148 | }); 149 | 150 | it('throws exception when downloading export file with invalid uuid', function(): void { 151 | actingAsSuperUser() 152 | ->get(route('igniterlabs.importexport.import_export', ['slug' => 'download/igniterlabs-importexport-menus/invalid-uuid'])) 153 | ->assertRedirect(); 154 | 155 | expect(flash()->messages()->first())->message->toBe(lang('igniterlabs.importexport::default.error_export_not_found')); 156 | }); 157 | 158 | it('does not download exported file when user has restricted access', function(): void { 159 | $userRole = UserRole::factory()->create([ 160 | 'permissions' => ['IgniterLabs.ImportExport.Manage' => 1], 161 | ]); 162 | 163 | $this 164 | ->actingAs(User::factory()->for($userRole, 'role')->create(), 'igniter-admin') 165 | ->get(route('igniterlabs.importexport.import_export', ['slug' => 'download/igniterlabs-importexport-menus/history-uuid'])); 166 | expect(flash()->messages()->first())->message->toBe(lang('igniter::admin.alert_user_restricted')); 167 | }); 168 | 169 | it('downloads exported file', function(): void { 170 | $history = History::factory()->create([ 171 | 'type' => 'export', 172 | 'code' => 'igniterlabs-importexport-menus', 173 | 'status' => 'completed', 174 | ]); 175 | 176 | File::put($history->getCsvPath(), File::get(__DIR__.'/../../_fixtures/valid_import_file.csv')); 177 | 178 | actingAsSuperUser() 179 | ->get(route('igniterlabs.importexport.import_export', ['slug' => 'download/igniterlabs-importexport-menus/'.$history->uuid])) 180 | ->assertDownload(); 181 | }); 182 | -------------------------------------------------------------------------------- /tests/Http/Actions/ImportControllerTest.php: -------------------------------------------------------------------------------- 1 | create([ 17 | 'code' => 'igniterlabs-importexport-menus', 18 | ]); 19 | 20 | File::put($history->getCsvPath(), File::get(__DIR__.'/../../_fixtures/valid_import_file.csv')); 21 | 22 | actingAsSuperUser() 23 | ->get(route('igniterlabs.importexport.import_export', ['slug' => 'import/igniterlabs-importexport-menus/'.$history->uuid])) 24 | ->assertOk(); 25 | 26 | File::delete($history->getCsvPath()); 27 | }); 28 | 29 | it('throws exception when registered import type columns is missing', function(): void { 30 | app()->instance(ImportExportManager::class, $importExportManagerMock = mock(ImportExportManager::class)); 31 | $importExportManagerMock->shouldReceive('getRecordConfig') 32 | ->once() 33 | ->with('import', 'type-with-missing-columns') 34 | ->andReturn([ 35 | 'label' => 'Custom Import', 36 | 'description' => 'Custom import description', 37 | 'model' => MenuImport::class, 38 | 'configFile' => [], 39 | ]); 40 | 41 | $history = History::factory()->create([ 42 | 'code' => 'type-with-missing-columns', 43 | ]); 44 | File::put($history->getCsvPath(), File::get(__DIR__.'/../../_fixtures/valid_import_file.csv')); 45 | 46 | actingAsSuperUser() 47 | ->get(route('igniterlabs.importexport.import_export', ['slug' => 'import/type-with-missing-columns/'.$history->uuid])) 48 | ->assertSee(lang('igniterlabs.importexport::default.error_empty_import_columns')); 49 | }); 50 | 51 | it('throws exception when import file has missing headers', function(): void { 52 | app()->instance(ImportExportManager::class, $importExportManagerMock = mock(ImportExportManager::class)); 53 | $importExportManagerMock->shouldReceive('getRecordConfig') 54 | ->once() 55 | ->with('import', 'type-with-missing-headers') 56 | ->andReturn([ 57 | 'label' => 'Custom Import', 58 | 'description' => 'Custom import description', 59 | 'model' => MenuImport::class, 60 | 'configFile' => [ 61 | 'columns' => [ 62 | 'column1' => 'Column', 63 | ], 64 | ], 65 | ]); 66 | 67 | $history = History::factory()->create([ 68 | 'code' => 'type-with-missing-headers', 69 | ]); 70 | File::put($history->getCsvPath(), File::get(__DIR__.'/../../_fixtures/valid_import_file.csv')); 71 | 72 | actingAsSuperUser() 73 | ->get(route('igniterlabs.importexport.import_export', ['slug' => 'import/type-with-missing-headers/'.$history->uuid])) 74 | ->assertSee(lang('igniterlabs.importexport::default.error_missing_csv_headers')); 75 | }); 76 | 77 | it('redirects to import page after uploading valid import file', function(): void { 78 | History::query()->truncate(); 79 | File::deleteDirectory(dirname((new History)->getCsvPath())); 80 | 81 | $file = UploadedFile::fake()->image('import_file.csv'); 82 | $response = actingAsSuperUser() 83 | ->post(route('igniterlabs.importexport.import_export'), [ 84 | 'code' => 'igniterlabs-importexport-menus', 85 | 'import_file' => $file, 86 | ], [ 87 | 'X-Requested-With' => 'XMLHttpRequest', 88 | 'X-IGNITER-REQUEST-HANDLER' => 'onLoadImportForm', 89 | ]); 90 | 91 | $history = History::query()->where([ 92 | ['type', 'import'], 93 | ['code', 'igniterlabs-importexport-menus'], 94 | ['status', 'pending'], 95 | ])->first(); 96 | 97 | $response->assertJsonFragment([ 98 | 'X_IGNITER_REDIRECT' => admin_url('igniterlabs/importexport/import_export/import/igniterlabs-importexport-menus/'.$history->uuid), 99 | ]); 100 | }); 101 | 102 | it('flashes validation error when loading import form with incorrect code or invalid file', function(): void { 103 | actingAsSuperUser() 104 | ->post(route('igniterlabs.importexport.import_export'), [ 105 | 'code' => 'igniterlabs-importexport-menus', 106 | 'import_file' => UploadedFile::fake()->image('import_file.jpg'), 107 | ], [ 108 | 'X-Requested-With' => 'XMLHttpRequest', 109 | 'X-IGNITER-REQUEST-HANDLER' => 'onLoadImportForm', 110 | ]) 111 | ->assertJsonFragment([ 112 | 'X_IGNITER_REDIRECT' => admin_url('igniterlabs/importexport/import_export'), 113 | ]); 114 | 115 | expect(flash()->messages()->first())->message->toBe('You must upload a valid csv file'); 116 | flash()->clear(); 117 | 118 | actingAsSuperUser() 119 | ->post(route('igniterlabs.importexport.import_export'), [ 120 | 'code' => 'invalid-code', 121 | 'import_file' => UploadedFile::fake()->image('import_file.csv'), 122 | ], [ 123 | 'X-Requested-With' => 'XMLHttpRequest', 124 | 'X-IGNITER-REQUEST-HANDLER' => 'onLoadImportForm', 125 | ]) 126 | ->assertJsonFragment([ 127 | 'X_IGNITER_REDIRECT' => admin_url('igniterlabs/importexport/import_export'), 128 | ]); 129 | 130 | expect(flash()->messages()->first())->message->toBe('invalid-code is not a registered import template'); 131 | }); 132 | 133 | it('flashes validation error when loading import form with restricted access', function(): void { 134 | $userRole = UserRole::factory()->create([ 135 | 'permissions' => ['IgniterLabs.ImportExport.Manage' => 1], 136 | ]); 137 | $user = User::factory()->for($userRole, 'role')->create(); 138 | 139 | $this 140 | ->actingAs($user, 'igniter-admin') 141 | ->post(route('igniterlabs.importexport.import_export'), [ 142 | 'code' => 'igniterlabs-importexport-menus', 143 | 'import_file' => UploadedFile::fake()->image('import_file.csv'), 144 | ], [ 145 | 'X-Requested-With' => 'XMLHttpRequest', 146 | 'X-IGNITER-REQUEST-HANDLER' => 'onLoadImportForm', 147 | ]) 148 | ->assertJsonFragment([ 149 | 'X_IGNITER_REDIRECT' => admin_url('igniterlabs/importexport/import_export'), 150 | ]); 151 | 152 | expect(flash()->messages()->first())->message->toBe(lang('igniter::admin.alert_user_restricted')); 153 | }); 154 | 155 | it('does not load import page when user has restricted access', function(): void { 156 | $userRole = UserRole::factory()->create([ 157 | 'permissions' => ['IgniterLabs.ImportExport.Manage' => 1], 158 | ]); 159 | $user = User::factory()->for($userRole, 'role')->create(); 160 | 161 | $this 162 | ->actingAs($user, 'igniter-admin') 163 | ->get(route('igniterlabs.importexport.import_export', ['slug' => 'import/igniterlabs-importexport-menus/valid-uuid'])) 164 | ->assertRedirectContains('igniterlabs/importexport/import_export'); 165 | 166 | expect(flash()->messages()->first())->message->toBe(lang('igniter::admin.alert_user_restricted')); 167 | }); 168 | 169 | it('does not load import form when import history does not exists', function(): void { 170 | actingAsSuperUser() 171 | ->get(route('igniterlabs.importexport.import_export', ['slug' => 'import/igniterlabs-importexport-menus/invalid-uuid'])) 172 | ->assertRedirectContains('igniterlabs/importexport/import_export'); 173 | }); 174 | 175 | it('deletes imported file', function(): void { 176 | History::flushEventListeners(); 177 | $history = History::factory()->create([ 178 | 'code' => 'igniterlabs-importexport-menus', 179 | ]); 180 | File::put($history->getCsvPath(), File::get(__DIR__.'/../../_fixtures/valid_import_file.csv')); 181 | 182 | actingAsSuperUser() 183 | ->post(route('igniterlabs.importexport.import_export', ['slug' => 'import/igniterlabs-importexport-menus/'.$history->uuid]), [], [ 184 | 'X-Requested-With' => 'XMLHttpRequest', 185 | 'X-IGNITER-REQUEST-HANDLER' => 'onDeleteImportFile', 186 | ]) 187 | ->assertJsonFragment([ 188 | 'X_IGNITER_REDIRECT' => admin_url('igniterlabs/importexport/import_export'), 189 | ]); 190 | 191 | expect(History::query()->find($history->getKey()))->toBeNull(); 192 | }); 193 | 194 | it('processes import from uploaded import file', function(): void { 195 | $history = History::factory()->create([ 196 | 'code' => 'igniterlabs-importexport-menus', 197 | ]); 198 | 199 | $columns = [ 200 | 'menu_id' => 'ID', 201 | 'menu_name' => 'Name', 202 | 'menu_price' => 'Price', 203 | 'menu_description' => 'Description', 204 | 'minimum_qty' => 'Minimum Qty', 205 | 'categories' => 'Category', 206 | 'menu_status' => 'Status', 207 | ]; 208 | 209 | File::put($history->getCsvPath(), File::get(__DIR__.'/../../_fixtures/valid_import_file.csv')); 210 | 211 | actingAsSuperUser() 212 | ->post(route('igniterlabs.importexport.import_export', ['slug' => 'import/igniterlabs-importexport-menus/'.$history->uuid]), [ 213 | 'delimiter' => ',', 214 | 'enclosure' => '"', 215 | 'escape' => '\\', 216 | 'ImportSecondary' => [ 217 | 'update_existing' => true, 218 | ], 219 | 'import_columns' => array_keys($columns), 220 | 'match_columns' => array_values($columns), 221 | ], [ 222 | 'X-Requested-With' => 'XMLHttpRequest', 223 | 'X-IGNITER-REQUEST-HANDLER' => 'onImport', 224 | ]) 225 | ->assertJsonFragment([ 226 | 'X_IGNITER_REDIRECT' => admin_url('igniterlabs/importexport/import_export'), 227 | ]); 228 | 229 | $history->refresh(); 230 | expect($history->error_message)->toContain('Created (1)', 'Updated (16)', 'Errors (1)') 231 | ->and($history->status)->toBe('completed'); 232 | }); 233 | -------------------------------------------------------------------------------- /tests/Http/Controllers/ImportExportTest.php: -------------------------------------------------------------------------------- 1 | count(5)->create(); 12 | History::factory()->count(5)->create([ 13 | 'type' => 'export', 14 | 'status' => 'completed', 15 | ]); 16 | 17 | actingAsSuperUser() 18 | ->get(route('igniterlabs.importexport.import_export')) 19 | ->assertOk(); 20 | }); 21 | 22 | it('loads import/export popup with correct context', function(string $context): void { 23 | actingAsSuperUser() 24 | ->post(route('igniterlabs.importexport.import_export'), [ 25 | 'context' => $context, 26 | ], [ 27 | 'X-Requested-With' => 'XMLHttpRequest', 28 | 'X-IGNITER-REQUEST-HANDLER' => 'onLoadPopup', 29 | ]) 30 | ->assertSee(lang('igniterlabs.importexport::default.text_'.$context.'_title')); 31 | })->with([ 32 | 'import' => 'import', 33 | 'export' => 'export', 34 | ]); 35 | 36 | it('throws exception when loading import/export popup with incorrect context', function(): void { 37 | actingAsSuperUser() 38 | ->post(route('igniterlabs.importexport.import_export'), [ 39 | 'context' => 'invalid', 40 | ], [ 41 | 'X-Requested-With' => 'XMLHttpRequest', 42 | 'X-IGNITER-REQUEST-HANDLER' => 'onLoadPopup', 43 | ]) 44 | ->assertSee('Invalid type specified'); 45 | }); 46 | 47 | it('does not load export records form page when registered record is missing model', function(): void { 48 | app()->instance(ImportExportManager::class, $importExportManagerMock = mock(ImportExportManager::class)); 49 | $importExportManagerMock->shouldReceive('getRecordConfig') 50 | ->once() 51 | ->with('export', 'type-with-missing-model') 52 | ->andReturn([ 53 | 'label' => 'Custom Export', 54 | 'description' => 'Custom export description', 55 | 'configFile' => [], 56 | ]); 57 | 58 | actingAsSuperUser() 59 | ->get(route('igniterlabs.importexport.import_export', ['slug' => 'export/type-with-missing-model'])) 60 | ->assertSee(sprintf(lang('igniterlabs.importexport::default.error_missing_model'), 'export')); 61 | }); 62 | -------------------------------------------------------------------------------- /tests/Models/MenuExportTest.php: -------------------------------------------------------------------------------- 1 | exportData([]); 13 | 14 | expect($result)->toBeArray(); 15 | }); 16 | 17 | it('returns array with menu categories when defined', function(): void { 18 | $menuExport = mock(MenuExport::class)->makePartial(); 19 | $menuExport->shouldReceive('extendableGet')->with('menu_categories')->andReturn(collect([['name' => 'Category 1']])); 20 | 21 | $result = $menuExport->getCategoriesAttribute(); 22 | 23 | expect($result)->toBeString() 24 | ->and($result)->toBe('Category 1'); 25 | }); 26 | 27 | it('returns encoded array of menu categories', function(): void { 28 | $menuExport = mock(MenuExport::class)->makePartial(); 29 | $menuExport->shouldReceive('extendableGet')->with('menu_categories')->andReturn(collect([ 30 | ['name' => 'Category 1'], 31 | ['name' => 'Category 2'], 32 | ])); 33 | 34 | $result = $menuExport->getCategoriesAttribute(); 35 | 36 | expect($result)->toBeString() 37 | ->and($result)->toBe('Category 1|Category 2'); 38 | }); 39 | 40 | it('configure menu export model correctly', function(): void { 41 | $menuExport = new MenuExport; 42 | 43 | expect($menuExport->getTable())->toBe('menus') 44 | ->and($menuExport->getKeyName())->toBe('menu_id') 45 | ->and($menuExport->relation)->toBe([ 46 | 'belongsToMany' => [ 47 | 'menu_categories' => [Category::class, 'table' => 'menu_categories', 'foreignKey' => 'menu_id'], 48 | ], 49 | ]) 50 | ->and($menuExport->getAppends())->toBe([ 51 | 'categories', 52 | ]); 53 | }); 54 | -------------------------------------------------------------------------------- /tests/Models/MenuImportTest.php: -------------------------------------------------------------------------------- 1 | makePartial()->shouldAllowMockingProtectedMethods(); 14 | $menuImport->shouldReceive('findDuplicateMenuItem')->andReturn(null); 15 | $menuImport->shouldReceive('getCategoryIdsForMenuItem')->andReturn([1, 2]); 16 | $menuImport->shouldReceive('logCreated')->once(); 17 | 18 | $data = [ 19 | 'menu_name' => 'New Menu Item', 20 | 'categories' => 'Category 1|Category 2', 21 | ]; 22 | 23 | $menuImport->importData([$data]); 24 | 25 | $menuItem = Menu::where('menu_name', 'New Menu Item')->first(); 26 | expect($menuItem)->not->toBeNull() 27 | ->and($menuItem->categories->pluck('category_id')->toArray())->toBe([1, 2]); 28 | }); 29 | 30 | it('imports data and updates existing menu item', function(): void { 31 | Menu::factory()->create(['menu_name' => 'Existing Menu Item']); 32 | $existingMenu = Menu::factory()->create(); 33 | $existingCategory = Category::factory()->create(); 34 | $menuImport = mock(MenuImport::class)->makePartial()->shouldAllowMockingProtectedMethods(); 35 | $menuImport->shouldReceive('extendableGet')->with('update_existing')->andReturn(true); 36 | $menuImport->shouldReceive('logUpdated')->times(3); 37 | 38 | $data = [ 39 | [ 40 | 'menu_name' => '', // Test skips empty menu name 41 | ], 42 | [ 43 | 'menu_name' => 'Existing Menu Item', 44 | ], 45 | [ 46 | 'menu_id' => $existingMenu->getKey(), 47 | 'menu_name' => 'Existing Menu Item 2', 48 | 'categories' => $existingCategory->name.'|'.$existingCategory->name, 49 | ], 50 | ]; 51 | 52 | $menuImport->importData($data); 53 | 54 | $menuItem = Menu::where('menu_name', 'Existing Menu Item 2')->first(); 55 | expect($menuItem)->not->toBeNull() 56 | ->and($menuItem->categories->pluck('category_id')->toArray())->toContain($existingCategory->getKey()); 57 | }); 58 | 59 | it('logs error when exception is thrown during import', function(): void { 60 | $menuImport = mock(MenuImport::class)->makePartial()->shouldAllowMockingProtectedMethods(); 61 | $menuImport->shouldReceive('extendableGet')->with('update_existing')->andReturn(true); 62 | $menuImport->shouldReceive('findDuplicateMenuItem')->andThrow(new Exception('Test Exception')); 63 | $menuImport->shouldReceive('logError')->with(0, 'Test Exception')->once(); 64 | 65 | $data = [ 66 | 'menu_name' => 'Menu Item', 67 | ]; 68 | 69 | $menuImport->importData([0 => $data]); 70 | }); 71 | -------------------------------------------------------------------------------- /tests/Pest.php: -------------------------------------------------------------------------------- 1 | in(__DIR__); 9 | 10 | function actingAsSuperUser() 11 | { 12 | return test()->actingAs(User::factory()->superUser()->create(), 'igniter-admin'); 13 | } 14 | -------------------------------------------------------------------------------- /tests/_fixtures/valid_import_file.csv: -------------------------------------------------------------------------------- 1 | ID,Name,Price,Description,"Minimum Quantity",Category,Status 2 | 1,Puff-Puff,4.9900,"Traditional Nigerian donut ball, rolled in sugar",3,,1 3 | 2,"SCOTCH EGG",2.0000,"Boiled egg wrapped in a ground meat mixture, coated in breadcrumbs, and deep-fried.",1,,1 4 | 3,"ATA RICE",12.0000,"Small pieces of beef, goat, stipe, and tendon sautéed in crushed green Jamaican pepper.",1,,1 5 | 4,"RICE AND DODO",11.9900,"(plantains) w/chicken, fish, beef or goat",1,,1 6 | 5,"Special Shrimp Deluxe",12.9900,"Fresh shrimp sautéed in blended mixture of tomatoes, onion, peppers over choice of rice",1,,1 7 | 6,"Whole catfish with rice and vegetables",13.9900,"Whole catfish slow cooked in tomatoes, pepper and onion sauce with seasoning to taste",1,,1 8 | 7,"African Salad",8.9900,"With baked beans, egg, tuna, onion, tomatoes , green peas and carrot with your choice of dressing.",1,,1 9 | 8,"Seafood Salad",5.9900,"With shrimp, egg and imitation crab meat",1,Specials,1 10 | 9,EBA,11.9900,"Grated cassava",1,,1 11 | 10,AMALA,11.9900,"Yam flour",1,Specials|,1 12 | 11,"YAM PORRIDGE",9.9900,"in tomatoes sauce",1,Appetizer|Salads|Seafoods|New Category,1 13 | 12,"Boiled Plantain",9.9900,"w/spinach soup",1,,1 14 | 13,,34.6043,,1,,1 15 | --------------------------------------------------------------------------------