partkeepr

fork of partkeepr
git clone https://git.e1e0.net/partkeepr.git
Log | Files | Refs | Submodules | README | LICENSE

commit a4a0dec46efe7c39721e707f04292ff2bc5fa253
parent 8eaf0de1bea2eeb09a0cf6e8477e735c95b96165
Author: Felicitus <felicitus@felicitus.org>
Date:   Tue, 13 Oct 2015 18:48:47 +0200

Added new shiny exporter. It rocks. Seriously. Try it.

Diffstat:
Mapp/AppKernel.php | 1+
Mapp/config/config_dunglas.yml | 1+
Mapp/config/config_partkeepr.yml | 10++++++++++
Mcomposer.json | 5++++-
Mcomposer.lock | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Asrc/PartKeepr/ExportBundle/DependencyInjection/Compiler/CsvFormatPass.php | 15+++++++++++++++
Asrc/PartKeepr/ExportBundle/DependencyInjection/Compiler/XmlExcelFormatPass.php | 15+++++++++++++++
Asrc/PartKeepr/ExportBundle/EventListener/AbstractResponderViewListener.php | 116+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/PartKeepr/ExportBundle/EventListener/CsvResponderViewListener.php | 14++++++++++++++
Asrc/PartKeepr/ExportBundle/EventListener/XmlExcelResponderViewListener.php | 15+++++++++++++++
Asrc/PartKeepr/ExportBundle/PartKeeprExportBundle.php | 17+++++++++++++++++
Msrc/PartKeepr/FrontendBundle/Resources/public/js/Components/Editor/EditorGrid.js | 5+++--
Asrc/PartKeepr/FrontendBundle/Resources/public/js/Components/Exporter/Exporter.js | 348+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/PartKeepr/FrontendBundle/Resources/public/js/Components/StockReport/AbstractStockHistoryGrid.js | 5+++--
Msrc/PartKeepr/FrontendBundle/Resources/public/js/Components/StorageLocation/StorageLocationEditor.js | 2+-
Asrc/PartKeepr/FrontendBundle/Resources/public/js/Components/Widgets/PagingToolbar.js | 36++++++++++++++++++++++++++++++++++++
Msrc/PartKeepr/FrontendBundle/Resources/public/js/Data/HydraModel.js | 25+++++++++++++++++++++++++
Asrc/PartKeepr/FrontendBundle/Resources/public/js/Util/Blob.js | 211+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/PartKeepr/FrontendBundle/Resources/public/js/Util/FileSaver.js | 270+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/PartKeepr/FrontendBundle/Resources/views/index.html.twig | 18+++++-------------
20 files changed, 1245 insertions(+), 21 deletions(-)

diff --git a/app/AppKernel.php b/app/AppKernel.php @@ -61,6 +61,7 @@ class AppKernel extends Kernel $bundles[] = new PartKeepr\ProjectBundle\PartKeeprProjectBundle(); $bundles[] = new PartKeepr\StorageLocationBundle\PartKeeprStorageLocationBundle(); $bundles[] = new PartKeepr\TipOfTheDayBundle\PartKeeprTipOfTheDayBundle(); + $bundles[] = new PartKeepr\ExportBundle\PartKeeprExportBundle(); return $bundles; } diff --git a/app/config/config_dunglas.yml b/app/config/config_dunglas.yml @@ -5,3 +5,4 @@ dunglas_api: pagination: items_per_page: client_can_change: true + supported_formats: [ "jsonld", "csv", "xlsx" ] diff --git a/app/config/config_partkeepr.yml b/app/config/config_partkeepr.yml @@ -1,4 +1,14 @@ services: + csv_responder_view_listener: + class: PartKeepr\ExportBundle\EventListener\CsvResponderViewListener + tags: + - { name: "kernel.event_listener", event: "kernel.view", method: "onKernelView" } + + xlsx_responder_view_listener: + class: PartKeepr\ExportBundle\EventListener\XmlExcelResponderViewListener + tags: + - { name: "kernel.event_listener", event: "kernel.view", method: "onKernelView" } + partkeepr_legacy_user_provider: class: PartKeepr\AuthBundle\Security\User\LegacyUserProvider arguments: diff --git a/composer.json b/composer.json @@ -65,7 +65,10 @@ "reputation-vip/composer-assets-installer": "^1.0", "jms/translation-bundle": "dev-master", "partkeepr/remote-file-loader": "dev-master", - "nfq-alpha/sprite-bundle": "^1.0" + "nfq-alpha/sprite-bundle": "^1.0", + "ddeboer/data-import": "@stable", + "symfony/property-access": "^2.7", + "sonata-project/exporter": "^1.4" }, "require-dev": { "phpunit/phpunit": "4.8.*", diff --git a/composer.lock b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "hash": "d213ac86e4359288dbc26d83f3987ced", + "hash": "9c4f10cbc6134adaf0a2ef4388ce50e8", "packages": [ { "name": "atelierspierrot/famfamfam-silk-sprite", @@ -282,6 +282,75 @@ "time": "2015-02-18 17:17:01" }, { + "name": "ddeboer/data-import", + "version": "0.18.0", + "source": { + "type": "git", + "url": "https://github.com/ddeboer/data-import.git", + "reference": "cbae2f570192b738c5cec835fbf583a75c52d535" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ddeboer/data-import/zipball/cbae2f570192b738c5cec835fbf583a75c52d535", + "reference": "cbae2f570192b738c5cec835fbf583a75c52d535", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "psr/log": "~1.0" + }, + "require-dev": { + "doctrine/dbal": "*", + "doctrine/orm": "*", + "ext-iconv": "*", + "ext-mbstring": "*", + "ext-sqlite3": "*", + "phpoffice/phpexcel": "*", + "symfony/console": "~2.5.0", + "symfony/property-access": "*", + "symfony/validator": "~2.3.0" + }, + "suggest": { + "doctrine/dbal": "If you want to use the DbalReader", + "ext-iconv": "For the CharsetValueConverter", + "ext-mbstring": "For the CharsetValueConverter", + "phpoffice/phpexcel": "If you want to use the ExcelReader", + "symfony/console": "If you want to use the ConsoleProgressWriter", + "symfony/property-access": "If you want to use the ObjectConverter", + "symfony/validator": "to use the ValidatorFilter" + }, + "type": "library", + "autoload": { + "psr-0": { + "Ddeboer\\DataImport": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "The community", + "homepage": "https://github.com/ddeboer/data-import/graphs/contributors" + }, + { + "name": "David de Boer", + "email": "david@ddeboer.nl" + } + ], + "description": "Import data from, and export data to, a range of file formats and media", + "keywords": [ + "csv", + "data", + "doctrine", + "excel", + "export", + "import" + ], + "time": "2015-04-21 14:06:20" + }, + { "name": "doctrine/annotations", "version": "v1.2.7", "source": { @@ -1292,7 +1361,7 @@ }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dunglas/DunglasApiBundle/zipball/59bfc3925521c1167e3d5b72ae0e1e1d4345e3eb", + "url": "https://api.github.com/repos/dunglas/DunglasApiBundle/zipball/7b96ac54a52616309f964bdced38b20ed2d1a66a", "reference": "b30de689f94410c7dc0e449f2ecc15cfa8e70f54", "shasum": "" }, @@ -3055,6 +3124,69 @@ "time": "2015-08-11 12:11:25" }, { + "name": "sonata-project/exporter", + "version": "1.4.0", + "source": { + "type": "git", + "url": "https://github.com/sonata-project/exporter.git", + "reference": "92835b5aac7a5300b3c34f4b52ed4327a1d1ed47" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sonata-project/exporter/zipball/92835b5aac7a5300b3c34f4b52ed4327a1d1ed47", + "reference": "92835b5aac7a5300b3c34f4b52ed4327a1d1ed47", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "fabpot/php-cs-fixer": "~0.1|~1.0", + "propel/propel1": "~1.6", + "symfony/phpunit-bridge": "~2.7", + "symfony/property-access": "~2.3", + "symfony/routing": "~2.3" + }, + "suggest": { + "ext-curl": "*", + "propel/propel1": "~1.6", + "symfony/property-access": "~2.3", + "symfony/routing": "~2.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1-dev" + } + }, + "autoload": { + "psr-0": { + "Exporter": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Thomas Rabaix", + "email": "thomas.rabaix@gmail.com", + "homepage": "https://sonata-project.org/" + } + ], + "description": "Lightweight Exporter library", + "homepage": "https://github.com/sonata-project/Exporter", + "keywords": [ + "client", + "csv", + "data", + "export", + "xls" + ], + "time": "2015-06-29 18:36:46" + }, + { "name": "stof/doctrine-extensions-bundle", "version": "dev-master", "source": { @@ -5108,6 +5240,7 @@ "escapestudios/wsse-authentication-bundle": 20, "jms/translation-bundle": 20, "partkeepr/remote-file-loader": 20, + "ddeboer/data-import": 0, "codeclimate/php-test-reporter": 20 }, "prefer-stable": false, diff --git a/src/PartKeepr/ExportBundle/DependencyInjection/Compiler/CsvFormatPass.php b/src/PartKeepr/ExportBundle/DependencyInjection/Compiler/CsvFormatPass.php @@ -0,0 +1,15 @@ +<?php +namespace PartKeepr\ExportBundle\DependencyInjection\Compiler; + + +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +class CsvFormatPass implements CompilerPassInterface +{ + public function process (ContainerBuilder $container) { + $container->getDefinition('api.format_negotiator')->addMethodCall('registerFormat', [ + 'csv', ['text/comma-separated-values'], true, + ]); + } +} diff --git a/src/PartKeepr/ExportBundle/DependencyInjection/Compiler/XmlExcelFormatPass.php b/src/PartKeepr/ExportBundle/DependencyInjection/Compiler/XmlExcelFormatPass.php @@ -0,0 +1,15 @@ +<?php +namespace PartKeepr\ExportBundle\DependencyInjection\Compiler; + + +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +class XmlExcelFormatPass implements CompilerPassInterface +{ + public function process (ContainerBuilder $container) { + $container->getDefinition('api.format_negotiator')->addMethodCall('registerFormat', [ + 'xlsx', ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'], true, + ]); + } +} diff --git a/src/PartKeepr/ExportBundle/EventListener/AbstractResponderViewListener.php b/src/PartKeepr/ExportBundle/EventListener/AbstractResponderViewListener.php @@ -0,0 +1,116 @@ +<?php +namespace PartKeepr\ExportBundle\EventListener; + +use Exporter\Writer\WriterInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Event\GetResponseForControllerResultEvent; +use Symfony\Component\PropertyAccess\PropertyAccess; + + +abstract class AbstractResponderViewListener +{ + const FORMAT = 'null'; + + /** + * Converts the response to an exported format. + * + * @param GetResponseForControllerResultEvent $event + * + * @return Response|mixed + */ + public function onKernelView(GetResponseForControllerResultEvent $event) + { + $controllerResult = $event->getControllerResult(); + + if ($controllerResult instanceof Response) { + return; + } + + $request = $event->getRequest(); + + $format = $request->attributes->get('_api_format'); + if (static::FORMAT !== $format) { + return; + } + + switch ($request->getMethod()) { + case Request::METHOD_POST: + $status = 201; + break; + + case Request::METHOD_DELETE: + $status = 204; + break; + + default: + $status = 200; + break; + } + + $columns = array(); + + if ($event->getRequest()->query->has("columns")) { + $columns = json_decode($event->getRequest()->query->get("columns")); + } + + $data = $this->flatten($controllerResult, $columns); + + + $file = tempnam(sys_get_temp_dir(), "partkeepr_export"); + unlink($file); + $writer = $this->getWriter($file); + $writer->open(); + foreach ($data as $item) { + $writer->write($item); + } + + $writer->close(); + + $exportData = file_get_contents($file); + + $event->setResponse(new Response($exportData, $status)); + } + + /** + * Returns the writer + * + * @param $file + * + * @return WriterInterface + */ + abstract protected function getWriter($file); + + /** + * Flattens the given data. Uses the property accessor to retrieve nested data. + * + * @param $data array The data, typically an array of entities + * @param $mappings array The mappings as array, e.g. [ "name", "description", "storageLocation.name" ] + * + * @return array + */ + protected function flatten($data, $mappings) + { + $accessor = PropertyAccess::createPropertyAccessor(); + $finalData = array(); + foreach ($data as $key => $row) { + foreach ($mappings as $mapping) { + try { + $finalData[$key][$mapping] = $accessor->getValue($row, $mapping); + + if (is_object($finalData[$key][$mapping])) { + if ($finalData[$key][$mapping] instanceof \DateTime) { + $finalData[$key][$mapping] = $finalData[$key][$mapping]->format(\DateTime::W3C); + } + } + } catch (\Exception $e) { + + } + + + } + } + + return $finalData; + } +} diff --git a/src/PartKeepr/ExportBundle/EventListener/CsvResponderViewListener.php b/src/PartKeepr/ExportBundle/EventListener/CsvResponderViewListener.php @@ -0,0 +1,14 @@ +<?php +namespace PartKeepr\ExportBundle\EventListener; + +use Exporter\Writer\CsvWriter; + + +class CsvResponderViewListener extends AbstractResponderViewListener +{ + const FORMAT = 'csv'; + + protected function getWriter($file) { + return new CsvWriter($file); + } +} diff --git a/src/PartKeepr/ExportBundle/EventListener/XmlExcelResponderViewListener.php b/src/PartKeepr/ExportBundle/EventListener/XmlExcelResponderViewListener.php @@ -0,0 +1,15 @@ +<?php +namespace PartKeepr\ExportBundle\EventListener; + +use Exporter\Writer\XmlExcelWriter; + + +class XmlExcelResponderViewListener extends AbstractResponderViewListener +{ + const FORMAT = 'xlsx'; + + protected function getWriter($file) + { + return new XmlExcelWriter($file); + } +} diff --git a/src/PartKeepr/ExportBundle/PartKeeprExportBundle.php b/src/PartKeepr/ExportBundle/PartKeeprExportBundle.php @@ -0,0 +1,17 @@ +<?php +namespace PartKeepr\ExportBundle; + + +use PartKeepr\ExportBundle\DependencyInjection\Compiler\CsvFormatPass; +use PartKeepr\ExportBundle\DependencyInjection\Compiler\XmlExcelFormatPass; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\HttpKernel\Bundle\Bundle; + +class PartKeeprExportBundle extends Bundle +{ + public function build(ContainerBuilder $container) + { + $container->addCompilerPass(new CsvFormatPass()); + $container->addCompilerPass(new XmlExcelFormatPass()); + } +} diff --git a/src/PartKeepr/FrontendBundle/Resources/public/js/Components/Editor/EditorGrid.js b/src/PartKeepr/FrontendBundle/Resources/public/js/Components/Editor/EditorGrid.js @@ -150,11 +150,12 @@ Ext.define('PartKeepr.EditorGrid', { items: topToolbarItems }); - this.bottomToolbar = Ext.create("Ext.toolbar.Paging", { + this.bottomToolbar = Ext.create("PartKeepr.PagingToolbar", { store: this.store, enableOverflow: true, dock: 'bottom', - displayInfo: false + displayInfo: false, + grid: this }); this.dockedItems = new Array(); diff --git a/src/PartKeepr/FrontendBundle/Resources/public/js/Components/Exporter/Exporter.js b/src/PartKeepr/FrontendBundle/Resources/public/js/Components/Exporter/Exporter.js @@ -0,0 +1,348 @@ +Ext.define("PartKeepr.Exporter.Exporter", { + extend: "Ext.panel.Panel", + layout: 'border', + items: [ + { + title: i18n("Preview"), + xtype: 'grid', + region: 'center', + itemId: 'grid', + }, { + title: i18n("Available fields"), + xtype: 'treepanel', + region: 'east', + width: 265, + itemId: 'fieldTree', + split: true, + store: { + folderSort: true, + sorters: [ + { + property: 'text', + direction: 'ASC' + } + ] + }, + useArrows: true + } + ], + /** + * @var {Array} Contains the models already in the field tree + */ + visitedModels: [], + + /** + * @var {Array} All configured columns + */ + columns: [], + + /** + * @var {Ext.data.Store} The store + */ + store: null, + + initComponent: function () + { + this.callParent(arguments); + this.visitedModels = []; + var schema = this.model.schema; + + var rootNode = this.down("#fieldTree").getRootNode(); + this.down("#fieldTree").on("itemdblclick", this.onTreeDblClick, this); + rootNode.set("text", this.model.getName()); + + this.treeMaker(rootNode, this.model, ""); + rootNode.expand(); + + this.store = Ext.create("Ext.data.Store", { + model: this.model.getName(), + autoLoad: true + }); + + this.formatStore = Ext.create("Ext.data.Store", { + fields: ['format', 'extension', 'mimetype'], + data: [ + { + "format": i18n("CSV"), + "extension": "csv", + "mimetype": "text/comma-separated-values" + }, + { + "format": i18n("Excel 2007 and later"), + "extension": "xlsx", + "mimetype": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + } + ] + }); + + this.formats = Ext.create("Ext.form.field.ComboBox", { + store: this.formatStore, + queryMode: "local", + displayField: "format", + forceSelection: true, + returnObject: true, + itemId: 'formatSelector', + value: this.formatStore.getAt(0) + }); + this.bottomToolbar = Ext.create("Ext.toolbar.Paging", { + store: this.store, + enableOverflow: true, + dock: 'bottom', + displayInfo: false + }); + + this.bottomToolbar.add([ + '-', + this.formats, + { + xtype: 'button', + iconCls: 'fugue-icon application-export', + handler: "doExport", + scope: this + } + ]); + + this.down("#fieldTree").addDocked({ + xtype: 'toolbar', + items: [ + { + xtype: 'button', + iconCls: 'web-icon add', + handler: "onAddColumn", + scope: this + }, + { + xtype: 'button', + iconCls: 'web-icon delete', + handler: "onRemoveColumn", + scope: this + } + ] + }); + this.down("#grid").addDocked(this.bottomToolbar); + + this.down("#grid").reconfigure(this.store, this.columns); + }, + /** + * Triggers the export. As we cannot force file downloads via XMLHttpRequest, + * we need to process the response in a callback. + */ + doExport: function () + { + var options = { + headers: {} + }; + + Ext.apply(options.headers, this.store.getProxy().getHeaders()); + options.headers["Accept"] = this.down("#formatSelector").getValue().get("mimetype"); + options.url = this.store.getProxy().getUrl() + "?" + Ext.Object.toQueryString(this.getParams()); + options.callback = Ext.bind(this.onExportSuccessful, this); + Ext.Ajax.request(options); + }, + /** + * Callback for when the export is complete. Creates a client-side blob object and forces + * download of it. + */ + onExportSuccessful: function (options, success, response) + { + var blob = new Blob([response.responseText], {type: this.down("#formatSelector").getValue().get("mimetype")}); + saveAs(blob, "export." + this.down("#formatSelector").getValue().get("extension")); + }, + /** + * Returns the parameters for the query string. + * @return {Object} An object containing all parameters + */ + getParams: function () + { + var i, originalColumns, columns = []; + originalColumns = this.down('#grid').getColumns(); + for (var i = 0; i < originalColumns.length; i++) { + columns.push(originalColumns[i].dataIndex); + } + + return { + itemsPerPage: 9999999, + columns: Ext.encode(columns) + }; + + }, + /** + * Event handler for the add button + */ + onAddColumn: function () + { + var selModel = this.down("#fieldTree").getSelectionModel(); + if (!selModel.hasSelection()) { + return; + } + + var record = this.down("#fieldTree").getSelectionModel().getSelection()[0]; + this.addColumn(record); + } + , + /** + * Event handler for the remove button + */ + onRemoveColumn: function () + { + var selModel = this.down("#fieldTree").getSelectionModel(); + if (!selModel.hasSelection()) { + return; + } + + var record = this.down("#fieldTree").getSelectionModel().getSelection()[0]; + this.removeColumn(record); + } + , + /** + * Adds a specific column to the grid. Must be a record and has the "data" property defined. + * + * @param {Ext.data.Model} The record to process + */ + addColumn: function (record) + { + var columns; + if (this.hasColumn(record) || record.get("data") === undefined) { + return; + } + + columns = this.down('#grid').getColumns(); + + this.syncColumns(); + + this.columns.push({ + dataIndex: record.get("data"), + text: record.get("data"), + renderer: function (value, metadata, record, rowIndex, colIndex, store, view) + { + return record.get(this.getColumns()[colIndex].dataIndex); + }, + scope: this.down('#grid') + }); + + this.down("#grid").reconfigure(this.store, this.columns); + } + , + /** + * Removes a specific column to the grid. Must be a record and has the "data" property defined. + * + * @param {Ext.data.Model} The record to process + */ + removeColumn: function (record) + { + var i; + + if (!this.hasColumn(record) || record.get("data") === undefined) { + return; + } + + this.syncColumns(); + + for (i = 0; i < this.columns.length; i++) { + if (this.columns[i].dataIndex === record.get("data")) { + Ext.Array.removeAt(this.columns, i); + } + } + this.down("#grid").reconfigure(this.store, this.columns); + + } + , + /** + * Syncronizes the internal columns storage with the grid. The reason it is done that way is because we can't + * operate on the return value of getColumns() directly, as these are instanciated objects which get removed + * during a reconfigure operation. + */ + syncColumns: function () + { + var columns; + this.columns = []; + + columns = this.down('#grid').getColumns(); + + for (i = 0; i < columns.length; i++) { + this.columns.push({ + dataIndex: columns[i].dataIndex, + text: columns[i].text, + renderer: function (value, metadata, record, rowIndex, colIndex, store, view) + { + return record.get(this.getColumns()[colIndex].dataIndex); + }, + scope: this.down('#grid') + }); + } + + } + , + /** + * Returns if a specific column exists in the grid.Must be a record and has the "data" property defined. + * + * @param {Ext.data.Model} The record to process + * @return {Boolean} true if the column exist, false otherwise + */ + hasColumn: function (record) + { + var columns = this.down('#grid').getColumns(); + + for (i = 0; i < columns.length; i++) { + if (columns[i].dataIndex === record.get("data")) { + return true; + } + } + + return false; + + } + , + /** + * Handles the double click on the tree. Adds the item if it doesn't exist, or remove it otherwise + * + * @param {Ext.tree.Tree} The tree panel + * @param {Ext.data.Model} The double clicked record + */ + onTreeDblClick: function (tree, record) + { + if (this.hasColumn(record)) { + this.removeColumn(record); + } else { + this.addColumn(record); + } + + } + , + /** + * Builds the field tree recursively. Handles infinite recursions (e.g. in trees). + * + * @param {Ext.data.NodeInterface} The current node + * @param {Ext.data.Model} The model + * @param {String} The prefix. Omit if first called + */ + treeMaker: function (node, model, prefix) + { + var fields = model.getFields(); + this.visitedModels.push(model.getName()); + for (var i = 0; i < fields.length; i++) { + + if (fields[i]["$reference"] === undefined) { + node.appendChild({ + text: fields[i].name, + leaf: true, + data: prefix + fields[i].name + }); + } else { + for (var j = 0; j < this.visitedModels.length; j++) { + if (this.visitedModels[j] === fields[i].reference.cls.getName()) { + return; + } + } + + var childNode = node.appendChild({ + text: fields[i].name, + leaf: false + }); + + this.treeMaker(childNode, fields[i].reference.cls, prefix + fields[i].name + "."); + } + + } + } +}); diff --git a/src/PartKeepr/FrontendBundle/Resources/public/js/Components/StockReport/AbstractStockHistoryGrid.js b/src/PartKeepr/FrontendBundle/Resources/public/js/Components/StockReport/AbstractStockHistoryGrid.js @@ -102,11 +102,12 @@ Ext.define('PartKeepr.AbstractStockHistoryGrid', { this.plugins = [this.editing]; - this.bottomToolbar = Ext.create("Ext.toolbar.Paging", { + this.bottomToolbar = Ext.create("PartKeepr.PagingToolbar", { store: this.store, enableOverflow: true, dock: 'bottom', - displayInfo: false + displayInfo: false, + grid: this }); diff --git a/src/PartKeepr/FrontendBundle/Resources/public/js/Components/StorageLocation/StorageLocationEditor.js b/src/PartKeepr/FrontendBundle/Resources/public/js/Components/StorageLocation/StorageLocationEditor.js @@ -20,7 +20,7 @@ Ext.define('PartKeepr.StorageLocationEditor', { this.store = Ext.create('Ext.data.Store', config); - this.bottomToolbar = Ext.create("Ext.toolbar.Paging", { + this.bottomToolbar = Ext.create("PartKeepr.PagingToolbar", { store: this.store, enableOverflow: true, dock: 'bottom', diff --git a/src/PartKeepr/FrontendBundle/Resources/public/js/Components/Widgets/PagingToolbar.js b/src/PartKeepr/FrontendBundle/Resources/public/js/Components/Widgets/PagingToolbar.js @@ -0,0 +1,36 @@ +Ext.define("PartKeepr.PagingToolbar", { + extend: "Ext.toolbar.Paging", + + grid: null, + + getPagingItems: function () { + var items = this.callParent(arguments); + + items.push({ + itemId: 'export', + tooltip: i18n("Export"), + iconCls: "fugue-icon application-export", + disabled: this.store.isLoading(), + handler: this.doExport, + scope: this + }); + return items; + }, + doExport: function () + { + var j = Ext.create("Ext.window.Window", { + items: Ext.create("PartKeepr.Exporter.Exporter", { + model: this.store.getModel() + }), + title: i18n("Export"), + width: "80%", + height: "80%", + layout: 'fit', + maximizable: true, + closeAction: 'destroy' + + }); + j.show(); + + } +}); diff --git a/src/PartKeepr/FrontendBundle/Resources/public/js/Data/HydraModel.js b/src/PartKeepr/FrontendBundle/Resources/public/js/Data/HydraModel.js @@ -13,6 +13,31 @@ Ext.define("PartKeepr.data.HydraModel", { return data; }, + get: function (fieldName) { + var ret, role, item; + + ret = this.callParent(arguments); + + if (ret === undefined) { + // The field is undefined, attempt to retrieve data via associations + var parts = fieldName.split("."); + + if (parts.length < 2) { + return ret; + } + + if (this.associations[parts[0]]) { + role = this.associations[parts[0]]; + item = role.getAssociatedItem(this); + + if (item !== null) { + parts.shift(); + return item.get(parts.join(".")); + } + } + } + return ret; + }, /** * Returns data from all associations * diff --git a/src/PartKeepr/FrontendBundle/Resources/public/js/Util/Blob.js b/src/PartKeepr/FrontendBundle/Resources/public/js/Util/Blob.js @@ -0,0 +1,211 @@ +/* Blob.js + * A Blob implementation. + * 2014-07-24 + * + * By Eli Grey, http://eligrey.com + * By Devin Samarin, https://github.com/dsamarin + * License: X11/MIT + * See https://github.com/eligrey/Blob.js/blob/master/LICENSE.md + */ + +/*global self, unescape */ +/*jslint bitwise: true, regexp: true, confusion: true, es5: true, vars: true, white: true, + plusplus: true */ + +/*! @source http://purl.eligrey.com/github/Blob.js/blob/master/Blob.js */ + +(function (view) { + "use strict"; + + view.URL = view.URL || view.webkitURL; + + if (view.Blob && view.URL) { + try { + new Blob; + return; + } catch (e) {} + } + + // Internally we use a BlobBuilder implementation to base Blob off of + // in order to support older browsers that only have BlobBuilder + var BlobBuilder = view.BlobBuilder || view.WebKitBlobBuilder || view.MozBlobBuilder || (function(view) { + var + get_class = function(object) { + return Object.prototype.toString.call(object).match(/^\[object\s(.*)\]$/)[1]; + } + , FakeBlobBuilder = function BlobBuilder() { + this.data = []; + } + , FakeBlob = function Blob(data, type, encoding) { + this.data = data; + this.size = data.length; + this.type = type; + this.encoding = encoding; + } + , FBB_proto = FakeBlobBuilder.prototype + , FB_proto = FakeBlob.prototype + , FileReaderSync = view.FileReaderSync + , FileException = function(type) { + this.code = this[this.name = type]; + } + , file_ex_codes = ( + "NOT_FOUND_ERR SECURITY_ERR ABORT_ERR NOT_READABLE_ERR ENCODING_ERR " + + "NO_MODIFICATION_ALLOWED_ERR INVALID_STATE_ERR SYNTAX_ERR" + ).split(" ") + , file_ex_code = file_ex_codes.length + , real_URL = view.URL || view.webkitURL || view + , real_create_object_URL = real_URL.createObjectURL + , real_revoke_object_URL = real_URL.revokeObjectURL + , URL = real_URL + , btoa = view.btoa + , atob = view.atob + + , ArrayBuffer = view.ArrayBuffer + , Uint8Array = view.Uint8Array + + , origin = /^[\w-]+:\/*\[?[\w\.:-]+\]?(?::[0-9]+)?/ + ; + FakeBlob.fake = FB_proto.fake = true; + while (file_ex_code--) { + FileException.prototype[file_ex_codes[file_ex_code]] = file_ex_code + 1; + } + // Polyfill URL + if (!real_URL.createObjectURL) { + URL = view.URL = function(uri) { + var + uri_info = document.createElementNS("http://www.w3.org/1999/xhtml", "a") + , uri_origin + ; + uri_info.href = uri; + if (!("origin" in uri_info)) { + if (uri_info.protocol.toLowerCase() === "data:") { + uri_info.origin = null; + } else { + uri_origin = uri.match(origin); + uri_info.origin = uri_origin && uri_origin[1]; + } + } + return uri_info; + }; + } + URL.createObjectURL = function(blob) { + var + type = blob.type + , data_URI_header + ; + if (type === null) { + type = "application/octet-stream"; + } + if (blob instanceof FakeBlob) { + data_URI_header = "data:" + type; + if (blob.encoding === "base64") { + return data_URI_header + ";base64," + blob.data; + } else if (blob.encoding === "URI") { + return data_URI_header + "," + decodeURIComponent(blob.data); + } if (btoa) { + return data_URI_header + ";base64," + btoa(blob.data); + } else { + return data_URI_header + "," + encodeURIComponent(blob.data); + } + } else if (real_create_object_URL) { + return real_create_object_URL.call(real_URL, blob); + } + }; + URL.revokeObjectURL = function(object_URL) { + if (object_URL.substring(0, 5) !== "data:" && real_revoke_object_URL) { + real_revoke_object_URL.call(real_URL, object_URL); + } + }; + FBB_proto.append = function(data/*, endings*/) { + var bb = this.data; + // decode data to a binary string + if (Uint8Array && (data instanceof ArrayBuffer || data instanceof Uint8Array)) { + var + str = "" + , buf = new Uint8Array(data) + , i = 0 + , buf_len = buf.length + ; + for (; i < buf_len; i++) { + str += String.fromCharCode(buf[i]); + } + bb.push(str); + } else if (get_class(data) === "Blob" || get_class(data) === "File") { + if (FileReaderSync) { + var fr = new FileReaderSync; + bb.push(fr.readAsBinaryString(data)); + } else { + // async FileReader won't work as BlobBuilder is sync + throw new FileException("NOT_READABLE_ERR"); + } + } else if (data instanceof FakeBlob) { + if (data.encoding === "base64" && atob) { + bb.push(atob(data.data)); + } else if (data.encoding === "URI") { + bb.push(decodeURIComponent(data.data)); + } else if (data.encoding === "raw") { + bb.push(data.data); + } + } else { + if (typeof data !== "string") { + data += ""; // convert unsupported types to strings + } + // decode UTF-16 to binary string + bb.push(unescape(encodeURIComponent(data))); + } + }; + FBB_proto.getBlob = function(type) { + if (!arguments.length) { + type = null; + } + return new FakeBlob(this.data.join(""), type, "raw"); + }; + FBB_proto.toString = function() { + return "[object BlobBuilder]"; + }; + FB_proto.slice = function(start, end, type) { + var args = arguments.length; + if (args < 3) { + type = null; + } + return new FakeBlob( + this.data.slice(start, args > 1 ? end : this.data.length) + , type + , this.encoding + ); + }; + FB_proto.toString = function() { + return "[object Blob]"; + }; + FB_proto.close = function() { + this.size = 0; + delete this.data; + }; + return FakeBlobBuilder; + }(view)); + + view.Blob = function(blobParts, options) { + var type = options ? (options.type || "") : ""; + var builder = new BlobBuilder(); + if (blobParts) { + for (var i = 0, len = blobParts.length; i < len; i++) { + if (Uint8Array && blobParts[i] instanceof Uint8Array) { + builder.append(blobParts[i].buffer); + } + else { + builder.append(blobParts[i]); + } + } + } + var blob = builder.getBlob(type); + if (!blob.slice && blob.webkitSlice) { + blob.slice = blob.webkitSlice; + } + return blob; + }; + + var getPrototypeOf = Object.getPrototypeOf || function(object) { + return object.__proto__; + }; + view.Blob.prototype = getPrototypeOf(new view.Blob()); +}(typeof self !== "undefined" && self || typeof window !== "undefined" && window || this.content || this)); diff --git a/src/PartKeepr/FrontendBundle/Resources/public/js/Util/FileSaver.js b/src/PartKeepr/FrontendBundle/Resources/public/js/Util/FileSaver.js @@ -0,0 +1,270 @@ +/* FileSaver.js + * A saveAs() FileSaver implementation. + * 1.1.20151003 + * + * By Eli Grey, http://eligrey.com + * License: X11/MIT + * See https://github.com/eligrey/FileSaver.js/blob/master/LICENSE.md + */ + +/*global self */ +/*jslint bitwise: true, indent: 4, laxbreak: true, laxcomma: true, smarttabs: true, plusplus: true */ + +/*! @source http://purl.eligrey.com/github/FileSaver.js/blob/master/FileSaver.js */ + +var saveAs = saveAs || (function(view) { + "use strict"; + // IE <10 is explicitly unsupported + if (typeof navigator !== "undefined" && /MSIE [1-9]\./.test(navigator.userAgent)) { + return; + } + var + doc = view.document + // only get URL when necessary in case Blob.js hasn't overridden it yet + , get_URL = function() { + return view.URL || view.webkitURL || view; + } + , save_link = doc.createElementNS("http://www.w3.org/1999/xhtml", "a") + , can_use_save_link = "download" in save_link + , click = function(node) { + var event = new MouseEvent("click"); + node.dispatchEvent(event); + } + , is_safari = /Version\/[\d\.]+.*Safari/.test(navigator.userAgent) + , webkit_req_fs = view.webkitRequestFileSystem + , req_fs = view.requestFileSystem || webkit_req_fs || view.mozRequestFileSystem + , throw_outside = function(ex) { + (view.setImmediate || view.setTimeout)(function() { + throw ex; + }, 0); + } + , force_saveable_type = "application/octet-stream" + , fs_min_size = 0 + // See https://code.google.com/p/chromium/issues/detail?id=375297#c7 and + // https://github.com/eligrey/FileSaver.js/commit/485930a#commitcomment-8768047 + // for the reasoning behind the timeout and revocation flow + , arbitrary_revoke_timeout = 500 // in ms + , revoke = function(file) { + var revoker = function() { + if (typeof file === "string") { // file is an object URL + get_URL().revokeObjectURL(file); + } else { // file is a File + file.remove(); + } + }; + if (view.chrome) { + revoker(); + } else { + setTimeout(revoker, arbitrary_revoke_timeout); + } + } + , dispatch = function(filesaver, event_types, event) { + event_types = [].concat(event_types); + var i = event_types.length; + while (i--) { + var listener = filesaver["on" + event_types[i]]; + if (typeof listener === "function") { + try { + listener.call(filesaver, event || filesaver); + } catch (ex) { + throw_outside(ex); + } + } + } + } + , auto_bom = function(blob) { + // prepend BOM for UTF-8 XML and text/* types (including HTML) + if (/^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test(blob.type)) { + return new Blob(["\ufeff", blob], {type: blob.type}); + } + return blob; + } + , FileSaver = function(blob, name, no_auto_bom) { + if (!no_auto_bom) { + blob = auto_bom(blob); + } + // First try a.download, then web filesystem, then object URLs + var + filesaver = this + , type = blob.type + , blob_changed = false + , object_url + , target_view + , dispatch_all = function() { + dispatch(filesaver, "writestart progress write writeend".split(" ")); + } + // on any filesys errors revert to saving with object URLs + , fs_error = function() { + if (target_view && is_safari && typeof FileReader !== "undefined") { + // Safari doesn't allow downloading of blob urls + var reader = new FileReader(); + reader.onloadend = function() { + var base64Data = reader.result; + target_view.location.href = "data:attachment/file" + base64Data.slice(base64Data.search(/[,;]/)); + filesaver.readyState = filesaver.DONE; + dispatch_all(); + }; + reader.readAsDataURL(blob); + filesaver.readyState = filesaver.INIT; + return; + } + // don't create more object URLs than needed + if (blob_changed || !object_url) { + object_url = get_URL().createObjectURL(blob); + } + if (target_view) { + target_view.location.href = object_url; + } else { + var new_tab = view.open(object_url, "_blank"); + if (new_tab == undefined && is_safari) { + //Apple do not allow window.open, see http://bit.ly/1kZffRI + view.location.href = object_url + } + } + filesaver.readyState = filesaver.DONE; + dispatch_all(); + revoke(object_url); + } + , abortable = function(func) { + return function() { + if (filesaver.readyState !== filesaver.DONE) { + return func.apply(this, arguments); + } + }; + } + , create_if_not_found = {create: true, exclusive: false} + , slice + ; + filesaver.readyState = filesaver.INIT; + if (!name) { + name = "download"; + } + if (can_use_save_link) { + object_url = get_URL().createObjectURL(blob); + save_link.href = object_url; + save_link.download = name; + setTimeout(function() { + click(save_link); + dispatch_all(); + revoke(object_url); + filesaver.readyState = filesaver.DONE; + }); + return; + } + // Object and web filesystem URLs have a problem saving in Google Chrome when + // viewed in a tab, so I force save with application/octet-stream + // http://code.google.com/p/chromium/issues/detail?id=91158 + // Update: Google errantly closed 91158, I submitted it again: + // https://code.google.com/p/chromium/issues/detail?id=389642 + if (view.chrome && type && type !== force_saveable_type) { + slice = blob.slice || blob.webkitSlice; + blob = slice.call(blob, 0, blob.size, force_saveable_type); + blob_changed = true; + } + // Since I can't be sure that the guessed media type will trigger a download + // in WebKit, I append .download to the filename. + // https://bugs.webkit.org/show_bug.cgi?id=65440 + if (webkit_req_fs && name !== "download") { + name += ".download"; + } + if (type === force_saveable_type || webkit_req_fs) { + target_view = view; + } + if (!req_fs) { + fs_error(); + return; + } + fs_min_size += blob.size; + req_fs(view.TEMPORARY, fs_min_size, abortable(function(fs) { + fs.root.getDirectory("saved", create_if_not_found, abortable(function(dir) { + var save = function() { + dir.getFile(name, create_if_not_found, abortable(function(file) { + file.createWriter(abortable(function(writer) { + writer.onwriteend = function(event) { + target_view.location.href = file.toURL(); + filesaver.readyState = filesaver.DONE; + dispatch(filesaver, "writeend", event); + revoke(file); + }; + writer.onerror = function() { + var error = writer.error; + if (error.code !== error.ABORT_ERR) { + fs_error(); + } + }; + "writestart progress write abort".split(" ").forEach(function(event) { + writer["on" + event] = filesaver["on" + event]; + }); + writer.write(blob); + filesaver.abort = function() { + writer.abort(); + filesaver.readyState = filesaver.DONE; + }; + filesaver.readyState = filesaver.WRITING; + }), fs_error); + }), fs_error); + }; + dir.getFile(name, {create: false}, abortable(function(file) { + // delete file if it already exists + file.remove(); + save(); + }), abortable(function(ex) { + if (ex.code === ex.NOT_FOUND_ERR) { + save(); + } else { + fs_error(); + } + })); + }), fs_error); + }), fs_error); + } + , FS_proto = FileSaver.prototype + , saveAs = function(blob, name, no_auto_bom) { + return new FileSaver(blob, name, no_auto_bom); + } + ; + // IE 10+ (native saveAs) + if (typeof navigator !== "undefined" && navigator.msSaveOrOpenBlob) { + return function(blob, name, no_auto_bom) { + if (!no_auto_bom) { + blob = auto_bom(blob); + } + return navigator.msSaveOrOpenBlob(blob, name || "download"); + }; + } + + FS_proto.abort = function() { + var filesaver = this; + filesaver.readyState = filesaver.DONE; + dispatch(filesaver, "abort"); + }; + FS_proto.readyState = FS_proto.INIT = 0; + FS_proto.WRITING = 1; + FS_proto.DONE = 2; + + FS_proto.error = + FS_proto.onwritestart = + FS_proto.onprogress = + FS_proto.onwrite = + FS_proto.onabort = + FS_proto.onerror = + FS_proto.onwriteend = + null; + + return saveAs; +}( + typeof self !== "undefined" && self + || typeof window !== "undefined" && window + || this.content +)); +// `self` is undefined in Firefox for Android content script context +// while `this` is nsIContentFrameMessageManager +// with an attribute `content` that corresponds to the window + +if (typeof module !== "undefined" && module.exports) { + module.exports.saveAs = saveAs; +} else if ((typeof define !== "undefined" && define !== null) && (define.amd != null)) { + define([], function() { + return saveAs; + }); +} diff --git a/src/PartKeepr/FrontendBundle/Resources/views/index.html.twig b/src/PartKeepr/FrontendBundle/Resources/views/index.html.twig @@ -56,6 +56,11 @@ {% endjavascripts %} {% javascripts output='js/compiled/main2.js' + '@PartKeeprFrontendBundle/Resources/public/js/Util/i18n.js' + '@PartKeeprFrontendBundle/Resources/public/js/Util/Blob.js' + '@PartKeeprFrontendBundle/Resources/public/js/Util/FileSaver.js' + '@PartKeeprFrontendBundle/Resources/public/js/Components/Widgets/PagingToolbar.js' + '@PartKeeprFrontendBundle/Resources/public/js/Components/Exporter/Exporter.js' '@PartKeeprFrontendBundle/Resources/public/js/Util/Filter.js' '@PartKeeprFrontendBundle/Resources/public/js/Components/Auth/LoginManager.js' '@PartKeeprFrontendBundle/Resources/public/js/ExtJS/Bugfixes/Ext.grid.feature.Summary-selectorFix.js' @@ -76,7 +81,6 @@ '@PartKeeprFrontendBundle/Resources/public/js/Util/Crypto/sha1.js' '@PartKeeprFrontendBundle/Resources/public/js/Util/Crypto/enc-base64.js' '@PartKeeprFrontendBundle/Resources/public/js/ExtJS/Bugfixes/Ext.data.Model-EXTJS-15037.js' - '@PartKeeprFrontendBundle/Resources/public/js/Util/i18n.js' '@PartKeeprFrontendBundle/Resources/public/js/Util/JsonWithAssociationsWriter.js' '@PartKeeprFrontendBundle/Resources/public/js/PartKeepr.js' '@PartKeeprFrontendBundle/Resources/public/js/compat.js' @@ -213,18 +217,6 @@ '@PartKeeprFrontendBundle/Resources/public/js/Ext.ux.Wizard.Header.js' '@PartKeeprFrontendBundle/Resources/public/js/Ext.ux.Wizard.js' '@PartKeeprFrontendBundle/Resources/public/js/Ext.ux.Wizard.CardLayout.js' - '@PartKeeprFrontendBundle/Resources/public/js/Ext.ux/Ext.ux.Exporter/excelFormatter/Cell.js' - '@PartKeeprFrontendBundle/Resources/public/js/Ext.ux/Ext.ux.Exporter/excelFormatter/Style.js' - '@PartKeeprFrontendBundle/Resources/public/js/Ext.ux/Ext.ux.Exporter/excelFormatter/Workbook.js' - '@PartKeeprFrontendBundle/Resources/public/js/Ext.ux/Ext.ux.Exporter/excelFormatter/Worksheet.js' - '@PartKeeprFrontendBundle/Resources/public/js/Ext.ux/Ext.ux.Exporter/Button.js' - '@PartKeeprFrontendBundle/Resources/public/js/Ext.ux/Ext.ux.Exporter/Formatter.js' - '@PartKeeprFrontendBundle/Resources/public/js/Ext.ux/Ext.ux.Exporter/excelFormatter/ExcelFormatter.js' - '@PartKeeprFrontendBundle/Resources/public/js/Ext.ux/Ext.ux.Exporter/csvFormatter/CsvFormatter.js' - '@PartKeeprFrontendBundle/Resources/public/js/Ext.ux/Ext.ux.Exporter/wikiFormatter/WikiFormatter.js' - '@PartKeeprFrontendBundle/Resources/public/js/Ext.ux/Ext.ux.Exporter/Base64.js' - '@PartKeeprFrontendBundle/Resources/public/js/Ext.ux/Ext.ux.Exporter/downloadify.min.js' - '@PartKeeprFrontendBundle/Resources/public/js/Ext.ux/Ext.ux.Exporter/Exporter.js' '@PartKeeprFrontendBundle/Resources/public/js/php.default.min.js' %} <script type="text/javascript" src="{{ asset_url }}"></script>