From d0742653d111bd3a86e8cc11ef939038198f97bd Mon Sep 17 00:00:00 2001 From: Samuel Georges Date: Thu, 23 Jul 2015 19:44:19 +1000 Subject: [PATCH] Implement exporting function modeled after import function --- .../behaviors/ImportExportController.php | 253 ++++++++++++++++-- .../assets/css/export.css | 7 + .../assets/js/october.export.js | 22 ++ .../assets/js/october.import.js | 49 ++-- .../assets/less/export.less | 13 + .../partials/_export.htm | 9 + .../partials/_export_columns.htm | 27 ++ .../partials/_export_form.htm | 51 ++++ .../partials/_export_result_form.htm | 34 +++ .../partials/_import.htm | 4 +- .../partials/fields_export.yaml | 58 ++++ modules/backend/models/ExportModel.php | 186 +++++++++++++ modules/backend/models/ImportModel.php | 32 ++- 13 files changed, 694 insertions(+), 51 deletions(-) create mode 100644 modules/backend/behaviors/importexportcontroller/assets/css/export.css create mode 100644 modules/backend/behaviors/importexportcontroller/assets/js/october.export.js create mode 100644 modules/backend/behaviors/importexportcontroller/assets/less/export.less create mode 100644 modules/backend/behaviors/importexportcontroller/partials/_export.htm create mode 100644 modules/backend/behaviors/importexportcontroller/partials/_export_columns.htm create mode 100644 modules/backend/behaviors/importexportcontroller/partials/_export_form.htm create mode 100644 modules/backend/behaviors/importexportcontroller/partials/_export_result_form.htm create mode 100644 modules/backend/behaviors/importexportcontroller/partials/fields_export.yaml create mode 100644 modules/backend/models/ExportModel.php diff --git a/modules/backend/behaviors/ImportExportController.php b/modules/backend/behaviors/ImportExportController.php index f70cf1fd4..243abf6b7 100644 --- a/modules/backend/behaviors/ImportExportController.php +++ b/modules/backend/behaviors/ImportExportController.php @@ -3,7 +3,6 @@ use Str; use Lang; use Backend\Classes\ControllerBehavior; -use League\Csv\Writer as CsvWriter; use League\Csv\Reader as CsvReader; use ApplicationException; use Exception; @@ -44,10 +43,35 @@ class ImportExportController extends ControllerBehavior protected $importUploadFormWidget; /** - * @var Backend\Classes\WidgetBase Reference to the widget used for specifing import options. + * @var Backend\Classes\WidgetBase Reference to the widget used for specifying import options. */ protected $importOptionsFormWidget; + /** + * @var Model Export model + */ + public $exportModel; + + /** + * @var array Export column configuration. + */ + public $exportColumns; + + /** + * @var string File name used for export output. + */ + protected $exportFileName = 'export.csv'; + + /** + * @var Backend\Classes\WidgetBase Reference to the widget used for standard export options. + */ + protected $exportFormatFormWidget; + + /** + * @var Backend\Classes\WidgetBase Reference to the widget used for custom export options. + */ + protected $exportOptionsFormWidget; + /** * Behavior constructor * @param Backend\Classes\Controller $controller @@ -57,13 +81,22 @@ class ImportExportController extends ControllerBehavior parent::__construct($controller); $this->addJs('js/october.import.js', 'core'); + $this->addJs('js/october.export.js', 'core'); $this->addCss('css/import.css', 'core'); + $this->addCss('css/export.css', 'core'); /* * Build configuration */ $this->config = $this->makeConfig($controller->importExportConfig, $this->requiredConfig); + /* + * Process config + */ + if ($exportFileName = $this->getConfig('export[fileName]')) { + $this->exportFileName = $exportFileName; + } + /* * Import form widgets */ @@ -75,6 +108,16 @@ class ImportExportController extends ControllerBehavior $this->importOptionsFormWidget->bindToController(); } + /* + * Export form widgets + */ + if ($this->exportFormatFormWidget = $this->makeExportFormatFormWidget()) { + $this->exportFormatFormWidget->bindToController(); + } + + if ($this->exportOptionsFormWidget = $this->makeExportOptionsFormWidget()) { + $this->exportOptionsFormWidget->bindToController(); + } } // @@ -86,12 +129,23 @@ class ImportExportController extends ControllerBehavior $this->controller->pageTitle = $this->controller->pageTitle ?: Lang::get($this->getConfig('import[title]', 'Import records')); - $this->prepareVars(); + $this->prepareImportVars(); } public function export() { - // TBA + $this->controller->pageTitle = $this->controller->pageTitle + ?: Lang::get($this->getConfig('export[title]', 'Export records')); + + $this->prepareExportVars(); + } + + public function download($name, $outputName = null) + { + $this->controller->pageTitle = $this->controller->pageTitle + ?: Lang::get($this->getConfig('export[title]', 'Export records')); + + return $this->exportGetModel()->download($name, $outputName); } // @@ -103,13 +157,13 @@ class ImportExportController extends ControllerBehavior try { $model = $this->importGetModel(); $matches = post('column_match', []); - $sessionKey = $this->importUploadFormWidget->getSessionKey(); if ($optionData = post('ImportOptions')) { $model->fill($optionData); } - $model->importDataFromColumnMatch($matches, $sessionKey, [ + $model->import($matches, [ + 'sessionKey' => $this->importUploadFormWidget->getSessionKey(), 'firstRowTitles' => post('first_row_titles', false) ]); @@ -178,7 +232,7 @@ class ImportExportController extends ControllerBehavior * Prepares the view data. * @return void */ - public function prepareVars() + public function prepareImportVars() { $this->vars['importUploadFormWidget'] = $this->importUploadFormWidget; $this->vars['importOptionsFormWidget'] = $this->importOptionsFormWidget; @@ -196,12 +250,7 @@ class ImportExportController extends ControllerBehavior public function importGetModel() { - if ($this->importModel !== null) { - return $this->importModel; - } - - $modelClass = $this->getConfig('import[modelClass]'); - return $this->importModel = new $modelClass; + return $this->getModelForType('import'); } protected function getImportDbColumns() @@ -233,6 +282,10 @@ class ImportExportController extends ControllerBehavior protected function makeImportUploadFormWidget() { + if (!$this->getConfig('import')) { + return null; + } + $widgetConfig = $this->makeConfig('~/modules/backend/behaviors/importexportcontroller/partials/fields_import.yaml'); $widgetConfig->model = $this->importGetModel(); $widgetConfig->alias = 'importUploadForm'; @@ -248,21 +301,14 @@ class ImportExportController extends ControllerBehavior protected function makeImportOptionsFormWidget() { - if ($fieldConfig = $this->getConfig('import[form]')) { - $widgetConfig = $this->makeConfig($fieldConfig); - $widgetConfig->model = $this->importGetModel(); - $widgetConfig->alias = 'importOptionsForm'; - $widgetConfig->arrayName = 'ImportOptions'; + $widget = $this->makeOptionsFormWidgetForType('import'); - $widget = $this->makeWidget('Backend\Widgets\Form', $widgetConfig); - return $widget; - } - elseif ($this->importUploadFormWidget) { + if (!$widget && $this->importUploadFormWidget) { $stepSection = $this->importUploadFormWidget->getField('step3_section'); $stepSection->hidden = true; } - return null; + return $widget; } protected function getImportFilePath() @@ -302,10 +348,136 @@ class ImportExportController extends ControllerBehavior } } + // + // Exporting AJAX + // + + public function onExport() + { + try { + $model = $this->exportGetModel(); + $columns = $this->processExportColumnsFromPost(); + $exportOptions = [ + 'sessionKey' => $this->exportFormatFormWidget->getSessionKey() + ]; + + if ($optionData = post('ExportOptions')) { + $model->fill($optionData); + } + + if (post('format_preset') == 'custom') { + $exportOptions['delimiter'] = post('format_delimiter'); + $exportOptions['enclosure'] = post('format_enclosure'); + $exportOptions['escape'] = post('format_escape'); + } + + $reference = $model->export($columns, $exportOptions); + + $this->vars['fileUrl'] = $this->controller->actionUrl( + 'download', + $reference.'/'.$this->exportFileName + ); + } + catch (Exception $ex) { + $this->controller->handleError($ex); + } + + return $this->importExportMakePartial('export_result_form'); + } + + public function onExportLoadForm() + { + return $this->importExportMakePartial('export_form'); + } + // // Exporting // + /** + * Prepares the view data. + * @return void + */ + public function prepareExportVars() + { + $this->vars['exportFormatFormWidget'] = $this->exportFormatFormWidget; + $this->vars['exportOptionsFormWidget'] = $this->exportOptionsFormWidget; + $this->vars['exportColumns'] = $this->getExportColumns(); + + // Make these variables available to widgets + $this->controller->vars += $this->vars; + } + + public function exportRender() + { + return $this->importExportMakePartial('export'); + } + + public function exportGetModel() + { + return $this->getModelForType('export'); + } + + protected function getExportColumns() + { + if ($this->exportColumns !== null) { + return $this->exportColumns; + } + $columnConfig = $this->getConfig('export[list]'); + return $this->exportColumns = $this->makeListColumns($columnConfig); + } + + protected function makeExportFormatFormWidget() + { + if (!$this->getConfig('export')) { + return null; + } + + $widgetConfig = $this->makeConfig('~/modules/backend/behaviors/importexportcontroller/partials/fields_export.yaml'); + $widgetConfig->model = $this->exportGetModel(); + $widgetConfig->alias = 'exportUploadForm'; + + $widget = $this->makeWidget('Backend\Widgets\Form', $widgetConfig); + + $widget->bindEvent('form.beforeRefresh', function($holder) { + $holder->data = []; + }); + + return $widget; + } + + protected function makeExportOptionsFormWidget() + { + $widget = $this->makeOptionsFormWidgetForType('export'); + + if (!$widget && $this->exportFormatFormWidget) { + $stepSection = $this->exportFormatFormWidget->getField('step3_section'); + $stepSection->hidden = true; + } + + return $widget; + } + + protected function processExportColumnsFromPost() + { + $visibleColumns = post('visible_columns', []); + $columns = post('export_columns', []); + + foreach ($columns as $key => $columnName) { + if (!isset($visibleColumns[$columnName])) { + unset($columns[$key]); + } + } + + $result = []; + $definitions = $this->getExportColumns(); + + foreach ($columns as $column) { + $result[$column] = array_get($definitions, $column, '???'); + } + + return $result; + } // // Helpers @@ -327,6 +499,41 @@ class ImportExportController extends ControllerBehavior return $contents; } + protected function makeOptionsFormWidgetForType($type) + { + if (!$this->getConfig($type)) { + return null; + } + + if ($fieldConfig = $this->getConfig($type.'[form]')) { + $widgetConfig = $this->makeConfig($fieldConfig); + $widgetConfig->model = $this->getModelForType($type); + $widgetConfig->alias = $type.'OptionsForm'; + $widgetConfig->arrayName = ucfirst($type).'Options'; + + $widget = $this->makeWidget('Backend\Widgets\Form', $widgetConfig); + return $widget; + } + + return null; + } + + protected function getModelForType($type) + { + $cacheProperty = $type.'Model'; + + if ($this->{$cacheProperty} !== null) { + return $this->{$cacheProperty}; + } + + $modelClass = $this->getConfig($type.'[modelClass]'); + if (!$modelClass) { + throw new ApplicationException('Please specify the modelClass property for '.$type); + } + + return $this->{$cacheProperty} = new $modelClass; + } + protected function makeListColumns($config) { $config = $this->makeConfig($config); diff --git a/modules/backend/behaviors/importexportcontroller/assets/css/export.css b/modules/backend/behaviors/importexportcontroller/assets/css/export.css new file mode 100644 index 000000000..1278b0efe --- /dev/null +++ b/modules/backend/behaviors/importexportcontroller/assets/css/export.css @@ -0,0 +1,7 @@ +.export-behavior .export-columns { + max-height: 400px; + background: #f0f0f0; + padding: 15px; + padding-bottom: 0; + overflow: auto; +} diff --git a/modules/backend/behaviors/importexportcontroller/assets/js/october.export.js b/modules/backend/behaviors/importexportcontroller/assets/js/october.export.js new file mode 100644 index 000000000..b6f8dd58b --- /dev/null +++ b/modules/backend/behaviors/importexportcontroller/assets/js/october.export.js @@ -0,0 +1,22 @@ +/* + * Scripts for the Export controller behavior. + */ ++function ($) { "use strict"; + + var ExportBehavior = function() { + + this.processExport = function () { + var $form = $('#exportColumns').closest('form') + + $form.request('onExport', { + success: function(data) { + $('#exportContainer').html(data.result) + $(document).trigger('render') + } + }) + } + + } + + $.oc.exportBehavior = new ExportBehavior; +}(window.jQuery); \ No newline at end of file diff --git a/modules/backend/behaviors/importexportcontroller/assets/js/october.import.js b/modules/backend/behaviors/importexportcontroller/assets/js/october.import.js index 0e863db55..dedcdaf6f 100644 --- a/modules/backend/behaviors/importexportcontroller/assets/js/october.import.js +++ b/modules/backend/behaviors/importexportcontroller/assets/js/october.import.js @@ -1,10 +1,34 @@ /* - * Scripts for the Import/Export controller behavior. + * Scripts for the Import controller behavior. */ +function ($) { "use strict"; var ImportBehavior = function() { + this.processImport = function () { + var $form = $('#importFileColumns').closest('form') + + $form.request('onImport', { + success: function(data) { + $('#importContainer').html(data.result) + $(document).trigger('render') + } + }) + } + + this.loadFileColumnSample = function(el) { + var $el = $(el), + $column = $el.closest('[data-column-id]'), + columnId = $column.data('column-id') + + $el.popup({ + handler: 'onImportLoadColumnSampleForm', + extraData: { + file_column_id: columnId + } + }) + } + this.bindColumnSorting = function() { /* * Unbind existing @@ -85,29 +109,6 @@ $('#showIgnoredColumnsButton').addClass('disabled') } - this.loadFileColumnSample = function(el) { - var $el = $(el), - $column = $el.closest('[data-column-id]'), - columnId = $column.data('column-id') - - $el.popup({ - handler: 'onImportLoadColumnSampleForm', - extraData: { - file_column_id: columnId - } - }) - } - - this.processImport = function () { - var $form = $('#importFileColumns').closest('form') - - $form.request('onImport', { - success: function(data) { - $('#importContainer').html(data.result) - $(document).trigger('render') - } - }) - } } $.oc.importBehavior = new ImportBehavior; diff --git a/modules/backend/behaviors/importexportcontroller/assets/less/export.less b/modules/backend/behaviors/importexportcontroller/assets/less/export.less new file mode 100644 index 000000000..3ff782ed5 --- /dev/null +++ b/modules/backend/behaviors/importexportcontroller/assets/less/export.less @@ -0,0 +1,13 @@ +@import "../../../../assets/less/core/boot.less"; + +.export-behavior { + + .export-columns { + max-height: 400px; + background: #f0f0f0; + padding: 15px; + padding-bottom: 0; + overflow: auto; + } + +} \ No newline at end of file diff --git a/modules/backend/behaviors/importexportcontroller/partials/_export.htm b/modules/backend/behaviors/importexportcontroller/partials/_export.htm new file mode 100644 index 000000000..298e690e0 --- /dev/null +++ b/modules/backend/behaviors/importexportcontroller/partials/_export.htm @@ -0,0 +1,9 @@ +
+ + render() ?> + + + render() ?> + + +
\ No newline at end of file diff --git a/modules/backend/behaviors/importexportcontroller/partials/_export_columns.htm b/modules/backend/behaviors/importexportcontroller/partials/_export_columns.htm new file mode 100644 index 000000000..589679624 --- /dev/null +++ b/modules/backend/behaviors/importexportcontroller/partials/_export_columns.htm @@ -0,0 +1,27 @@ +
+
+
    + $column): ?> +
  • +
    + + + +
    +
  • + +
+
+
\ No newline at end of file diff --git a/modules/backend/behaviors/importexportcontroller/partials/_export_form.htm b/modules/backend/behaviors/importexportcontroller/partials/_export_form.htm new file mode 100644 index 000000000..2cd6aa806 --- /dev/null +++ b/modules/backend/behaviors/importexportcontroller/partials/_export_form.htm @@ -0,0 +1,51 @@ +
+ fatalError): ?> + + 'exportForm']) ?> + + +
+ +
+ + + + + + + + + + + + +
\ No newline at end of file diff --git a/modules/backend/behaviors/importexportcontroller/partials/_export_result_form.htm b/modules/backend/behaviors/importexportcontroller/partials/_export_result_form.htm new file mode 100644 index 000000000..03ccfed82 --- /dev/null +++ b/modules/backend/behaviors/importexportcontroller/partials/_export_result_form.htm @@ -0,0 +1,34 @@ +fatalError): ?> + + + + + + + + + + + + \ No newline at end of file diff --git a/modules/backend/behaviors/importexportcontroller/partials/_import.htm b/modules/backend/behaviors/importexportcontroller/partials/_import.htm index 05adaa725..864c7097b 100644 --- a/modules/backend/behaviors/importexportcontroller/partials/_import.htm +++ b/modules/backend/behaviors/importexportcontroller/partials/_import.htm @@ -2,6 +2,8 @@ render() ?> - render() ?> + + render() ?> + \ No newline at end of file diff --git a/modules/backend/behaviors/importexportcontroller/partials/fields_export.yaml b/modules/backend/behaviors/importexportcontroller/partials/fields_export.yaml new file mode 100644 index 000000000..81dfba5ac --- /dev/null +++ b/modules/backend/behaviors/importexportcontroller/partials/fields_export.yaml @@ -0,0 +1,58 @@ +# =================================== +# Field Definitions +# =================================== + +fields: + step1_section: + label: 1. Export output format + type: section + + format_preset: + label: File format + type: dropdown + default: standard + options: + standard: Standard format + custom: Custom format + span: left + + format_delimiter: + label: Delimiter character + default: ',' + span: left + trigger: + action: show + condition: value[custom] + field: format_preset + + format_enclosure: + label: Enclosure character + span: auto + default: '"' + trigger: + action: show + condition: value[custom] + field: format_preset + + format_escape: + label: Escape character + span: auto + default: '\\' + trigger: + action: show + condition: value[custom] + field: format_preset + + step2_section: + label: 2. Select columns to export + type: section + + export_columns: + label: Columns + type: partial + path: ~/modules/backend/behaviors/importexportcontroller/partials/_export_columns.htm + span: left + + step3_section: + label: 3. Set export options + type: section \ No newline at end of file diff --git a/modules/backend/models/ExportModel.php b/modules/backend/models/ExportModel.php new file mode 100644 index 000000000..cd496dfaa --- /dev/null +++ b/modules/backend/models/ExportModel.php @@ -0,0 +1,186 @@ + 'Some attribute value', + * 'db_name2' => 'Another attribute value' + * ], + * [...] + * + */ + abstract public function exportData($columns, $sessionKey = null); + + /** + * Export data based on column names and labels. + * The $columns array should be in the format of: + * + * [ + * 'db_name1' => 'Column label', + * 'db_name2' => 'Another label', + * ... + * ] + * + */ + public function export($columns, $options) + { + $sessionKey = array_get($options, 'sessionKey'); + $data = $this->exportData(array_keys($columns), $sessionKey); + return $this->processExportData($columns, $data, $options); + } + + /** + * Download a previously compiled export file. + * @return void + */ + public function download($name, $outputName = null) + { + if (!preg_match('/^oc[0-9a-z]*$/i', $name)) { + throw new ApplicationException('File not found'); + } + + $csvPath = temp_path() . '/' . $name; + if (!file_exists($csvPath)) { + throw new ApplicationException('File not found'); + } + + $headers = Response::download($csvPath, $outputName)->headers->all(); + $result = Response::make(File::get($csvPath), 200, $headers); + + @unlink($csvPath); + + return $result; + } + + /** + * Converts a data collection to a CSV file. + */ + protected function processExportData($columns, $results, $options) + { + /* + * Validate + */ + if (!$results) { + throw new ApplicationException('There was no data supplied to export'); + } + + /* + * Parse options + */ + $defaultOptions = [ + 'useOutput' => false, + 'fileName' => 'export.csv', + 'delimiter' => null, + 'enclosure' => null, + 'escape' => null + ]; + + $options = array_merge($defaultOptions, $options); + $columns = $this->exportExtendColumns($columns); + + /* + * Prepare CSV + */ + $csv = CsvWriter::createFromFileObject(new SplTempFileObject); + + if ($options['delimiter'] !== null) { + $csv->setDelimiter($options['delimiter']); + } + + if ($options['enclosure'] !== null) { + $csv->setEnclosure($options['enclosure']); + } + + if ($options['escape'] !== null) { + $csv->setEscape($options['escape']); + } + + /* + * Add headers + */ + $headers = $this->getColumnHeaders($columns); + $csv->insertOne($headers); + + /* + * Add records + */ + foreach ($results as $result) { + $data = $this->matchDataToColumns($result, $columns); + $csv->insertOne($data); + } + + /* + * Output + */ + if ($options['useOutput']) { + $csv->output($options['fileName']); + } + + /* + * Save for download + */ + $csvName = uniqid('oc'); + $csvPath = temp_path().'/'.$csvName; + $output = $csv->__toString(); + + File::put($csvPath, $output); + + return $csvName; + } + + /** + * Used to override column definitions at export time. + */ + protected function exportExtendColumns($columns) + { + return $columns; + } + + /** + * Extracts the headers from the column definitions. + */ + protected function getColumnHeaders($columns) + { + $headers = []; + + foreach ($columns as $column => $label) { + $headers[] = Lang::get($label); + } + + return $headers; + } + + /** + * Ensures the correct order of the column data. + */ + protected function matchDataToColumns($data, $columns) + { + $results = []; + + foreach ($columns as $column => $label) { + $results[] = array_get($data, $column); + } + + return $results; + } + +} \ No newline at end of file diff --git a/modules/backend/models/ImportModel.php b/modules/backend/models/ImportModel.php index 3d45f1ffb..9047b9b7b 100644 --- a/modules/backend/models/ImportModel.php +++ b/modules/backend/models/ImportModel.php @@ -26,6 +26,9 @@ abstract class ImportModel extends Model 'import_file' => ['System\Models\File'] ]; + /** + * @var array Import statistics store. + */ protected $resultStats = [ 'updated' => 0, 'created' => 0, @@ -36,11 +39,33 @@ abstract class ImportModel extends Model /** * Called when data is being imported. + * The $results array should be in the format of: + * + * [ + * 'db_name1' => 'Some value', + * 'db_name2' => 'Another value' + * ], + * [...] + * */ abstract public function importData($results, $sessionKey = null); - public function importDataFromColumnMatch($matches, $sessionKey = null, $options = []) + /** + * Import data based on column names matching header indexes in the CSV. + * The $matches array should be in the format of: + * + * [ + * 0 => [db_name1, db_name2], + * 1 => [db_name3], + * ... + * ] + * + * The key (0, 1) is the column index in the CSV and the value + * is another array of target database column names. + */ + public function import($matches, $options = []) { + $sessionKey = array_get($options, 'sessionKey'); $path = $this->getImportFilePath($sessionKey); $data = $this->processImportData($path, $matches, $options); return $this->importData($data, $sessionKey); @@ -48,11 +73,12 @@ abstract class ImportModel extends Model /** * Converts column index to database column map to an array containing - * database column names and values pulled from the CSV file. - * Eg: + * database column names and values pulled from the CSV file. Eg: + * * [0 => [first_name], 1 => [last_name]] * * Will return: + * * [first_name => Joe, last_name => Blogs], * [first_name => Harry, last_name => Potter], * [...]