partkeepr

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

commit 4bb55d49e870c5b5483121692d4323e8a4e00635
parent 7560d83fbc38fb35a11e740064c3317c6893beb8
Author: Felicia Hummel <felicitus@felicitus.org>
Date:   Mon, 21 Nov 2016 17:49:43 +0100

Merge pull request #747 from partkeepr/PartKeepr-163

Initial importer commit, yes I should do more atomar commits :(
Diffstat:
Mapp/AppKernel.php | 1+
Mapp/config/config_partkeepr.yml | 5++---
Mapp/config/routing.yml | 3+++
Msrc/PartKeepr/DoctrineReflectionBundle/Filter/AdvancedSearchFilter.php | 69+++++++++++++++++++++++++++++++++++++++++----------------------------
Asrc/PartKeepr/DoctrineReflectionBundle/Filter/SearchFilter.php | 10++++++++++
Msrc/PartKeepr/DoctrineReflectionBundle/Resources/views/model.js.twig | 5+++--
Msrc/PartKeepr/DoctrineReflectionBundle/Services/ReflectionService.php | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/PartKeepr/FrontendBundle/Resources/public/js/Components/BarcodeScanner/Actions/AddPart.js | 2+-
Msrc/PartKeepr/FrontendBundle/Resources/public/js/Components/BarcodeScanner/Actions/AddRemoveStock.js | 2+-
Msrc/PartKeepr/FrontendBundle/Resources/public/js/Components/BarcodeScanner/Actions/SearchPart.js | 2+-
Msrc/PartKeepr/FrontendBundle/Resources/public/js/Components/Exporter/Exporter.js | 271+------------------------------------------------------------------------------
Asrc/PartKeepr/FrontendBundle/Resources/public/js/Components/Importer/GridImporterButton.js | 24++++++++++++++++++++++++
Asrc/PartKeepr/FrontendBundle/Resources/public/js/Components/Importer/ImportFieldMatcherGrid.js | 199+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/PartKeepr/FrontendBundle/Resources/public/js/Components/Importer/Importer.js | 399+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/PartKeepr/FrontendBundle/Resources/public/js/Components/Importer/ImporterEntityConfiguration.js | 128+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/PartKeepr/FrontendBundle/Resources/public/js/Components/Importer/ImporterFieldConfiguration.js | 140+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/PartKeepr/FrontendBundle/Resources/public/js/Components/Importer/ImporterManyToOneConfiguration.js | 360+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/PartKeepr/FrontendBundle/Resources/public/js/Components/Importer/ImporterOneToManyConfiguration.js | 278+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/PartKeepr/FrontendBundle/Resources/public/js/Components/ModelTreeMaker/ModelTreeMaker.js | 136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/PartKeepr/FrontendBundle/Resources/public/js/Components/Project/ProjectPartGrid.js | 9++++++++-
Asrc/PartKeepr/FrontendBundle/Resources/public/js/Components/Widgets/EntityPicker.js | 39+++++++++++++++++++++++++++++++++++++++
Asrc/PartKeepr/FrontendBundle/Resources/public/js/Components/Widgets/EntityQueryPanel.js | 269+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/PartKeepr/FrontendBundle/Resources/public/js/Components/Widgets/FieldSelector.js | 86++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Msrc/PartKeepr/FrontendBundle/Resources/public/js/Components/Widgets/PagingToolbar.js | 7+++++++
Msrc/PartKeepr/FrontendBundle/Resources/views/index.html.twig | 10++++++++++
Asrc/PartKeepr/ImportBundle/Configuration/BaseConfiguration.php | 40++++++++++++++++++++++++++++++++++++++++
Asrc/PartKeepr/ImportBundle/Configuration/Configuration.php | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/PartKeepr/ImportBundle/Configuration/FieldConfiguration.php | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/PartKeepr/ImportBundle/Configuration/ManyToOneConfiguration.php | 187+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/PartKeepr/ImportBundle/Controller/ImportController.php | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/PartKeepr/ImportBundle/DependencyInjection/Configuration.php | 29+++++++++++++++++++++++++++++
Asrc/PartKeepr/ImportBundle/DependencyInjection/PartKeeprImportExtension.php | 28++++++++++++++++++++++++++++
Asrc/PartKeepr/ImportBundle/PartKeeprImportBundle.php | 9+++++++++
Asrc/PartKeepr/ImportBundle/Resources/config/routing.yml | 3+++
Asrc/PartKeepr/ImportBundle/Resources/config/services.xml | 16++++++++++++++++
Asrc/PartKeepr/ImportBundle/Service/ImporterService.php | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/PartKeepr/PartBundle/DataFixtures/PartDataLoader.php | 2++
Msrc/PartKeepr/PartBundle/Entity/Part.php | 13++++++++++---
Msrc/PartKeepr/PartBundle/Tests/InternalPartNumberTest.php | 1+
39 files changed, 2933 insertions(+), 322 deletions(-)

diff --git a/app/AppKernel.php b/app/AppKernel.php @@ -64,6 +64,7 @@ class AppKernel extends Kernel $bundles[] = new PartKeepr\ExportBundle\PartKeeprExportBundle(); $bundles[] = new PartKeepr\StatisticBundle\PartKeeprStatisticBundle(); $bundles[] = new PartKeepr\SystemPreferenceBundle\PartKeeprSystemPreferenceBundle(); + $bundles[] = new PartKeepr\ImportBundle\PartKeeprImportBundle(); return array_merge($bundles, $this->getCustomBundles()); } diff --git a/app/config/config_partkeepr.yml b/app/config/config_partkeepr.yml @@ -346,7 +346,7 @@ services: - method: "initFilters" arguments: [ [ "@doctrine_reflection_service.search_filter" ] ] - method: "initNormalizationContext" - arguments: [ { groups: [ "default" ] } ] + arguments: [ { groups: [ "default", "readonly" ] } ] - method: "initDenormalizationContext" arguments: - { groups: [ "default", "stock" ] } @@ -1284,4 +1284,4 @@ services: arguments: [ [ "@resource.system_preference.item_operation.set_preference", "@resource.system_preference.item_operation.delete_preference" ] ] - method: "initDenormalizationContext" arguments: - - { groups: [ "default" ] }- \ No newline at end of file + - { groups: [ "default" ] } diff --git a/app/config/routing.yml b/app/config/routing.yml @@ -36,6 +36,9 @@ PartKeeprPartBundle: _frontend: resource: "@PartKeeprFrontendBundle/Resources/config/routing.yml" +_import: + resource: "@PartKeeprImportBundle/Resources/config/routing.yml" + _export: resource: "@PartKeeprExportBundle/Resources/config/routing.yml" diff --git a/src/PartKeepr/DoctrineReflectionBundle/Filter/AdvancedSearchFilter.php b/src/PartKeepr/DoctrineReflectionBundle/Filter/AdvancedSearchFilter.php @@ -7,7 +7,6 @@ use Doctrine\ORM\QueryBuilder; use Dunglas\ApiBundle\Api\IriConverterInterface; use Dunglas\ApiBundle\Api\ResourceInterface; use Dunglas\ApiBundle\Doctrine\Orm\Filter\AbstractFilter; -use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; @@ -76,18 +75,33 @@ class AdvancedSearchFilter extends AbstractFilter */ public function apply(ResourceInterface $resource, QueryBuilder $queryBuilder) { - $metadata = $this->getClassMetadata($resource); - $request = $this->requestStack->getCurrentRequest(); if (null === $request) { return; } - $properties = $this->extractProperties($request); + if ($request->query->has('filter')) { + $filter = json_decode($request->query->get("filter")); + } else { + $filter = null; + } + + if ($request->query->has('order')) { + $order = json_decode($request->query->get("order")); + } else { + $order = null; + } + + $properties = $this->extractConfiguration($filter, $order); $filters = $properties['filters']; $sorters = $properties['sorters']; + $this->filter($queryBuilder, $filters, $sorters); + } + + public function filter(QueryBuilder $queryBuilder, $filters, $sorters) + { foreach ($filters as $filter) { /** * @var Filter $filter @@ -289,36 +303,30 @@ class AdvancedSearchFilter extends AbstractFilter /** * {@inheritdoc} */ - protected function extractProperties(Request $request) + public function extractConfiguration($filterData, $sorterData) { $filters = []; - if ($request->query->has('filter')) { - $data = json_decode($request->query->get('filter')); - - if (is_array($data)) { - foreach ($data as $filter) { - $filters[] = $this->extractJSONFilters($filter); - } - } elseif (is_object($data)) { - $filters[] = $this->extractJSONFilters($data); + if (is_array($filterData)) { + foreach ($filterData as $filter) { + $filters[] = $this->extractJSONFilters($filter); } + } elseif (is_object($filterData)) { + $filters[] = $this->extractJSONFilters($filterData); } $sorters = []; - if ($request->query->has('order')) { - $data = json_decode($request->query->get('order')); - if (is_array($data)) { - foreach ($data as $sorter) { - $sorters[] = $this->extractJSONSorters($sorter); - } - } elseif (is_object($data)) { - $sorters[] = $this->extractJSONSorters($data); + if (is_array($sorterData)) { + foreach ($sorterData as $sorter) { + $sorters[] = $this->extractJSONSorters($sorter); } + } elseif (is_object($sorterData)) { + $sorters[] = $this->extractJSONSorters($sorterData); } + return ['filters' => $filters, 'sorters' => $sorters]; } @@ -329,8 +337,9 @@ class AdvancedSearchFilter extends AbstractFilter * * @return string The table alias */ - private function getAlias($property) - { + private function getAlias( + $property + ) { if (!array_key_exists($property, $this->aliases)) { $this->aliases[$property] = 't'.count($this->aliases); } @@ -347,8 +356,10 @@ class AdvancedSearchFilter extends AbstractFilter * * @return Filter */ - private function extractJSONFilters($data) - { + private + function extractJSONFilters( + $data + ) { $filter = new Filter(); if (property_exists($data, 'property')) { @@ -407,8 +418,10 @@ class AdvancedSearchFilter extends AbstractFilter * * @return Sorter A Sorter object */ - private function extractJSONSorters($data) - { + private + function extractJSONSorters( + $data + ) { $sorter = new Sorter(); if ($data->property) { diff --git a/src/PartKeepr/DoctrineReflectionBundle/Filter/SearchFilter.php b/src/PartKeepr/DoctrineReflectionBundle/Filter/SearchFilter.php @@ -0,0 +1,10 @@ +<?php + + +namespace PartKeepr\DoctrineReflectionBundle\Filter; + + +class SearchFilter +{ + +} diff --git a/src/PartKeepr/DoctrineReflectionBundle/Resources/views/model.js.twig b/src/PartKeepr/DoctrineReflectionBundle/Resources/views/model.js.twig @@ -5,14 +5,15 @@ Ext.define('{{ className }}', { idProperty: "@id", fields: [ {% for field in fields %} - { name: '{{ field.name|raw }}'{% if field.type%}, type: '{{ field.type }}'{% endif %}}{% if not loop.last %},{% endif %} + { name: '{{ field.name|raw }}'{% if field.type%}, type: '{{ field.type }}'{% endif %}{% if not field.persist %}, persist: false{% endif %}{% if field.validators %}, validators: {{ field.validators|raw }}{% endif %}}{% if not loop.last %},{% endif %} {% endfor %} {% if associations.MANY_TO_ONE|length > 0 %} , {% for association in associations.MANY_TO_ONE %} { name: '{{ association.name }}', - reference: '{{ association.target }}' + reference: '{{ association.target }}', + allowBlank: {% if association.nullable %}true{% else %}false{% endif %} {% if association.byReference %},byReference: true{% endif %} }{% if not loop.last %},{% endif %} diff --git a/src/PartKeepr/DoctrineReflectionBundle/Services/ReflectionService.php b/src/PartKeepr/DoctrineReflectionBundle/Services/ReflectionService.php @@ -8,6 +8,8 @@ use Doctrine\ORM\EntityManager; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Mapping\ClassMetadataInfo; use Symfony\Component\Templating\EngineInterface; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\NotBlank; class ReflectionService { @@ -152,6 +154,19 @@ class ReflectionService $getterField .= 's'; } + $propertyAnnotations = $this->reader->getPropertyAnnotations($cm->getReflectionProperty($association['fieldName'])); + + var_dump($propertyAnnotations); + $nullable = true; + + foreach ($propertyAnnotations as $propertyAnnotation) { + $filter = "Symfony\\Component\\Validator\\Constraints\\NotNull"; + + if (substr(get_class($propertyAnnotation),0, strlen($filter)) === $filter) { + $nullable = false; + } + } + // The self-referencing association may not be written for trees, because ExtJS can't load all nodes // in one go. if (!($bTree && $association['targetEntity'] == $cm->getName())) { @@ -162,6 +177,7 @@ class ReflectionService } $associationMappings[$associationType][] = [ 'name' => $association['fieldName'], + 'nullable' => $nullable, 'target' => $this->convertPHPToExtJSClassName($association['targetEntity']), 'byReference' => $byReference, 'getter' => $getter, @@ -240,9 +256,12 @@ class ReflectionService $fieldMappings = []; $fields = $cm->getFieldNames(); + foreach ($fields as $field) { $currentMapping = $cm->getFieldMapping($field); + $asserts = $this->getExtJSAssertMappings($cm, $field); + if ($currentMapping['fieldName'] == 'id') { $currentMapping['fieldName'] = '@id'; $currentMapping['type'] = 'string'; @@ -251,6 +270,8 @@ class ReflectionService $fieldMappings[] = [ 'name' => $currentMapping['fieldName'], 'type' => $this->getExtJSFieldMapping($currentMapping['type']), + 'validators' => json_encode($asserts), + 'persist' => $this->allowPersist($cm, $field) ]; } @@ -296,6 +317,54 @@ class ReflectionService return 'undefined'; } + public function getExtJSAssertMapping (Constraint $assert) { + switch (get_class($assert)) { + case "Symfony\\Component\\Validator\\Constraints\\NotBlank": + /** + * @var $assert NotBlank + */ + return [ "type" => "presence", "message" => $assert->message]; + break; + default: + return false; + } + } + + public function getExtJSAssertMappings (ClassMetadata $cm, $field) { + $asserts = []; + $propertyAnnotations = $this->reader->getPropertyAnnotations($cm->getReflectionProperty($field)); + + foreach ($propertyAnnotations as $propertyAnnotation) { + $filter = "Symfony\\Component\\Validator\\Constraints\\"; + + if (substr(get_class($propertyAnnotation),0, strlen($filter)) === $filter) { + $assertMapping = $this->getExtJSAssertMapping($propertyAnnotation); + + if ($assertMapping !== false) { + $asserts[] = $assertMapping; + } + } + } + + return $asserts; + } + + public function allowPersist (ClassMetadata $cm, $field) { + + $groupsAnnotation = $this->reader->getPropertyAnnotation( + $cm->getReflectionProperty($field), + 'Symfony\Component\Serializer\Annotation\Groups' + ); + + if ($groupsAnnotation !== null) { + if (in_array("readonly", $groupsAnnotation->getGroups())) { + return false; + } + + } + return true; + } + /** * Converts a PHP class name with namespaces to an ExtJS class name with namespaces. * @@ -303,7 +372,7 @@ class ReflectionService * * @return string */ - protected function convertPHPToExtJSClassName($className) + public function convertPHPToExtJSClassName($className) { return str_replace('\\', '.', $className); } @@ -315,7 +384,7 @@ class ReflectionService * * @return string */ - protected function convertExtJSToPHPClassName($className) + public function convertExtJSToPHPClassName($className) { return str_replace('.', '\\', $className); } diff --git a/src/PartKeepr/FrontendBundle/Resources/public/js/Components/BarcodeScanner/Actions/AddPart.js b/src/PartKeepr/FrontendBundle/Resources/public/js/Components/BarcodeScanner/Actions/AddPart.js @@ -99,7 +99,7 @@ Ext.define("PartKeepr.BarcodeScanner.Actions.AddPart", { var fields = []; for (var i = 0; i < selection.length; i++) { - fields.push(selection[i].data.data); + fields.push(selection[i].data.data.name); } configuration.searchFields = fields; diff --git a/src/PartKeepr/FrontendBundle/Resources/public/js/Components/BarcodeScanner/Actions/AddRemoveStock.js b/src/PartKeepr/FrontendBundle/Resources/public/js/Components/BarcodeScanner/Actions/AddRemoveStock.js @@ -92,7 +92,7 @@ Ext.define("PartKeepr.BarcodeScanner.Actions.AddRemoveStock", { var fields = []; for (var i = 0; i < selection.length; i++) { - fields.push(selection[i].data.data); + fields.push(selection[i].data.data.name); } configuration.searchFields = fields; configuration.searchMode = this.down("#searchMode").getValue().searchMode; diff --git a/src/PartKeepr/FrontendBundle/Resources/public/js/Components/BarcodeScanner/Actions/SearchPart.js b/src/PartKeepr/FrontendBundle/Resources/public/js/Components/BarcodeScanner/Actions/SearchPart.js @@ -92,7 +92,7 @@ Ext.define("PartKeepr.BarcodeScanner.Actions.SearchPart", { var fields = []; for (var i = 0; i < selection.length; i++) { - fields.push(selection[i].data.data); + fields.push(selection[i].data.data.name); } configuration.searchFields = fields; configuration.searchMode = this.down("#searchMode").getValue().searchMode; diff --git a/src/PartKeepr/FrontendBundle/Resources/public/js/Components/Exporter/Exporter.js b/src/PartKeepr/FrontendBundle/Resources/public/js/Components/Exporter/Exporter.js @@ -1,63 +1,11 @@ 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, + extend: "PartKeepr.Widgets.EntityQueryPanel", 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.down("#grid").setTitle(i18n("Preview")); this.formatStore = Ext.create("Ext.data.Store", { fields: ['format', 'extension', 'mimetype'], @@ -84,6 +32,7 @@ Ext.define("PartKeepr.Exporter.Exporter", { itemId: 'formatSelector', value: this.formatStore.getAt(0) }); + this.bottomToolbar = Ext.create("Ext.toolbar.Paging", { store: this.store, enableOverflow: true, @@ -102,23 +51,6 @@ Ext.define("PartKeepr.Exporter.Exporter", { } ]); - 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); @@ -147,202 +79,5 @@ Ext.define("PartKeepr.Exporter.Exporter", { { 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/Importer/GridImporterButton.js b/src/PartKeepr/FrontendBundle/Resources/public/js/Components/Importer/GridImporterButton.js @@ -0,0 +1,24 @@ +Ext.define("PartKeepr.Importer.GridImporterButton", { + extend: "Ext.button.Button", + + initComponent: function () + { + this.handler = this.onImport; + this.callParent(arguments); + }, + onImport: function () { + var j = Ext.create("Ext.window.Window", { + items: Ext.create("PartKeepr.Importer.Importer", { + model: this.up("gridpanel").getStore().getModel() + }), + title: i18n("Import"), + width: "80%", + height: "80%", + layout: 'fit', + maximizable: true, + closeAction: 'destroy' + + }); + j.show(); + } +}); diff --git a/src/PartKeepr/FrontendBundle/Resources/public/js/Components/Importer/ImportFieldMatcherGrid.js b/src/PartKeepr/FrontendBundle/Resources/public/js/Components/Importer/ImportFieldMatcherGrid.js @@ -0,0 +1,199 @@ +Ext.define("PartKeepr.Importer.ImportFieldMatcherGrid", { + extend: "Ext.grid.Panel", + xtype: 'importFieldMatcherGrid', + plugins: { + ptype: 'cellediting', + clicksToEdit: 1 + }, + bbar: [ + { + xtype: 'button', + text: i18n("Add…"), + iconCls: 'fugue-icon flask--plus', + itemId: 'addImporterMatchField' + }, + { + xtype: 'button', + text: i18n("Delete"), + iconCls: 'fugue-icon flask--minus', + itemId: 'deleteImporterMatchField', + disabled: true + } + ], + store: { + fields: ['matchField', 'importField'] + }, + + fieldSelectorWindow: null, + + initComponent: function () + { + this.callParent(arguments); + this.down("#addImporterMatchField").on("click", this.onAddMatchField, this); + this.down("#deleteImporterMatchField").on("click", this.onDeleteMatchField, this); + this.on("selectionchange", this.onSelectionChange, this); + this.getStore().on("update", function () + { + this.fireEvent("change"); + }, this); + this.reconfigureColumns(null); + + + }, + onSelectionChange: function (model, selected) + { + if (selected.length === 1) { + this.down("#deleteImporterMatchField").enable(); + } else { + this.down("#deleteImporterMatchField").disable(); + } + }, + reconfigureColumns: function (columnsStore) + { + var columns = [ + { + text: i18n("Match field"), + dataIndex: "matchField", + width: 200 + }, { + text: i18n("Import Field"), + dataIndex: "importField", + width: 200, + renderer: function (val) + { + if (columnsStore.getAt(val) !== null) { + return columnsStore.getAt(val).get("headerName"); + } else { + return ""; + } + }, + editor: { + xtype: "combo", + forceSelection: true, + editable: false, + queryMode: "local", + store: columnsStore, + displayField: "headerName", + valueField: "headerIndex" + } + } + ]; + this.reconfigure(null, columns); + }, + addRecord: function (matchField) + { + this.fieldSelectorWindow.close(); + this.store.add({"matchField": matchField, "importField": ""}); + this.fireEvent("change"); + }, + setModel: function (model, ignoreModel) + { + this.model = model; + + if (ignoreModel === undefined) { + this.ignoreModel = null; + } else { + this.ignoreModel = ignoreModel; + } + }, + onDeleteMatchField: function () + { + if (this.getSelection().length === 1) { + this.getStore().remove(this.getSelection()[0]); + } + }, + getImporterConfig: function () + { + var data = this.getStore().getData(); + var serializedRecords = []; + var serializedRecord; + + + for (var i = 0; i < data.getCount(); i++) { + serializedRecord = data.getAt(i).getData(); + delete serializedRecord.id; + serializedRecords.push(serializedRecord); + } + + return serializedRecords; + }, + setImporterConfig: function (config) + { + this.getStore().removeAll(); + for (var i = 0; i < config.length; i++) { + this.getStore().add(config[i]); + } + }, + onAddMatchField: function () + { + var excludeFields = []; + + for (var j = 0; j < this.store.getCount(); j++) { + excludeFields.push(this.store.getAt(j).get("matchField")); + } + + var modelFieldSelector = Ext.create({ + xtype: 'modelFieldSelector', + id: 'searchPartFieldSelector', + border: false, + sourceModel: this.model, + useCheckBoxes: false, + excludeFields: excludeFields, + excludeModels: [this.ignoreModel], + flex: 1, + listeners: { + selectionchange: function (selectionModel, selected) + { + var addFieldButton = this.up("#matchFieldWindow").down("#addSelectedField"); + + if (selected.length == 1 && selected[0].data.data.type == "field") { + addFieldButton.enable(); + } else { + addFieldButton.disable(); + } + } + } + }); + + modelFieldSelector.on("itemdblclick", function (view, record) + { + if (record.data.data && record.data.data.type == "field") { + this.addRecord(record.data.data.name); + } + }, this); + + this.fieldSelectorWindow = Ext.create("Ext.window.Window", { + title: i18n("Select match field"), + itemId: 'matchFieldWindow', + height: 400, + modal: true, + width: 600, + layout: { + type: 'vbox', + pack: 'start', + align: 'stretch' + }, + items: [ + modelFieldSelector + ], + bbar: [ + { + xtype: 'button', + itemId: 'addSelectedField', + disabled: true, + text: i18n("Add selected Field"), + iconCls: 'fugue-icon flask--plus', + handler: function () + { + var selection = modelFieldSelector.getSelection(); + + if (selection.length == 1 && selection[0].data.data.type == "field") { + this.addRecord(selection[0].data.data.name); + } + }, + scope: this + } + ], + }).show(); + } +}); diff --git a/src/PartKeepr/FrontendBundle/Resources/public/js/Components/Importer/Importer.js b/src/PartKeepr/FrontendBundle/Resources/public/js/Components/Importer/Importer.js @@ -0,0 +1,399 @@ +Ext.define("PartKeepr.Importer.Importer", { + extend: "Ext.panel.Panel", + layout: 'border', + bbar: [ + { + xtype: 'button', + itemId: "selectImportFile", + text: i18n("Select CSV file for import…") + }, + { + xtype: 'button', + itemId: "executeImport", + text: i18n("Execute import") + } + ], + items: [ + { + title: i18n("Mapping"), + xtype: 'treepanel', + region: 'west', + width: 400, + split: true, + itemId: 'fieldTree', + columns: [ + { + xtype: 'treecolumn', + text: i18n("Field"), + dataIndex: 'text', + width: 200, + }, { + xtype: 'checkcolumn', + disabled: true, + disabledCls: '', + header: i18n("Required"), + dataIndex: 'required', + width: 70, + }, { + header: i18n("Mapping"), + width: 100 + } + ], + store: { + folderSort: true, + sorters: [ + { + property: 'text', + direction: 'ASC' + } + ] + }, + useArrows: true + }, { + title: i18n("Configuration"), + region: 'center', + itemId: 'configurationCards', + layout: 'card', + items: [ + { + html: "Select a field to begin", + getImporterConfig: function () + { + return {}; + }, + setImporterConfig: function () + { + } + }, + { + xtype: 'importerEntityConfiguration', + itemId: 'importerEntityConfiguration', + }, { + xtype: 'importerFieldConfiguration', + itemId: 'importerFieldConfiguration' + }, + { + xtype: 'importerOneToManyConfiguration', + itemId: 'importerOneToManyConfiguration' + }, + { + xtype: 'importerManyToOneConfiguration', + itemId: 'importerManyToOneConfiguration' + } + ] + }, + { + xtype: 'panel', + region: 'east', + width: 300, + split: true, + itemId: "debugger", + scollable: true + }, + { + xtype: 'tabpanel', + region: 'south', + height: 265, + split: true, + items: [ + { + title: i18n("Source File"), + itemId: 'sourceFileGrid', + xtype: 'grid' + }, { + title: i18n("Preview"), + itemId: 'preview', + bodyStyle: "overflow: scroll;" + } + ] + } + ], + + /** + * @var {String} The model to use + */ + model: null, + + importConfiguration: {}, + + importColumnsStore: null, + + initComponent: function () + { + this.callParent(arguments); + + this.importConfiguration = {}; + + var rootNode = this.down("#fieldTree").getRootNode(); + rootNode.set("text", this.model.getName()); + rootNode.set("data", { + name: "", + type: "relation", + reference: this.model, + configuration: this.importConfiguration + }); + + var treeMaker = Ext.create("PartKeepr.ModelTreeMaker.ModelTreeMaker"); + treeMaker.addIgnoreField("@id"); + treeMaker.setCustomFieldIgnorer(this.customFieldIgnorer); + + treeMaker.make(rootNode, this.model, "", Ext.bind(this.appendFieldData, this)); + rootNode.expand(); + + this.down("#importerEntityConfiguration").setModel(this.model); + + this.importColumnsStore = Ext.create("Ext.data.Store", { + fields: ["headerIndex", "headerName"], + storeId: "importColumns" + }); + + this.down("#fieldTree").on("selectionchange", this.onFieldChange, this); + this.down("#fieldTree").on("beforeselect", this.onBeforeSelect, this); + this.down("#selectImportFile").on("click", this.uploadCSVFile, this); + this.down("#executeImport").on("click", this.executeImport, this); + this.down("#importerEntityConfiguration").on("configChanged", this.onConfigChange, this); + this.down("#importerFieldConfiguration").on("configChanged", this.onConfigChange, this); + this.down("#importerOneToManyConfiguration").on("configChanged", this.onConfigChange, this); + this.down("#importerManyToOneConfiguration").on("configChanged", this.onConfigChange, this); + }, + executeImport: function () + { + //@todo Implement warning dialog + + Ext.Ajax.request({ + url: PartKeepr.getBasePath() + '/executeImport/?file=' + this.temporaryFile, + method: 'POST', + params: { + configuration: Ext.encode(this.importConfiguration), + baseEntity: this.model.getName() + }, + success: function (response) + { + var response = Ext.decode(response.responseText); + + var j = Ext.create("Ext.window.Window", { + width: 400, + height: 400, + layout: "fit", + items: [ + { + xtype: 'panel', + itemId: 'resultPanel', + listeners: { + render: function (p) { + p.getEl().dom.innerHTML = "<pre><strong>Import Results</strong>\n\n" + response.logs + "</pre>"; + } + } + } + ] + }); + + j.show(); + }, + scope: this + }); + + }, + uploadCSVFile: function () + { + var j = Ext.create("PartKeepr.FileUploadDialog"); + j.on("fileUploaded", this.onFileUploaded, this); + j.show(); + }, + onFileUploaded: function (data) + { + var uploadedFile = Ext.create("PartKeepr.UploadedFileBundle.Entity.TempUploadedFile", data); + this.loadData(uploadedFile.getId()); + }, + onBeforeSelect: function () + { + this.onConfigChange(); + }, + onConfigChange: function () + { + //this.importConfiguration[activeItem.getImporterField()] = activeItem.getImporterConfig(); + + var str = JSON.stringify(this.importConfiguration, undefined, 4); + + this.down("#debugger").body.dom.innerHTML = "<pre>" + str + "</pre>"; + + Ext.Function.defer(this.refreshPreview, 100, this); + + }, + refreshPreview: function () + { + + Ext.Ajax.request({ + + url: PartKeepr.getBasePath() + '/getPreview/?file=' + this.temporaryFile, + method: 'POST', + params: { + configuration: Ext.encode(this.importConfiguration), + baseEntity: this.model.getName() + }, + success: function (response) + { + var response = Ext.decode(response.responseText); + this.down("#preview").body.dom.innerHTML = "<pre>" + response.logs + "</pre>"; + }, + scope: this + }); + }, + onFieldChange: function (selectionModel, selected) + { + if (selected.length == 1) { + if (selected[0].data.data.type == "field") { + this.down("#configurationCards").setActiveItem(this.down("#importerFieldConfiguration")); + } else { + if (selected[0].data.data.name === "") { + this.down("#configurationCards").setActiveItem(this.down("#importerEntityConfiguration")); + this.down("#importerEntityConfiguration").setModel(selected[0].data.data.reference); + } else { + if (selected[0].data.data.type === "onetomany") { + this.down("#configurationCards").setActiveItem(this.down("#importerOneToManyConfiguration")); + this.down("#importerOneToManyConfiguration").setModel(selected[0].data.data.reference, + selected[0].parentNode.data.data.reference); + } else { + this.down("#configurationCards").setActiveItem(this.down("#importerManyToOneConfiguration")); + this.down("#importerManyToOneConfiguration").setModel(selected[0].data.data.reference); + } + } + } + + this.down("#configurationCards").getLayout().getActiveItem().setImporterConfig( + selected[0].data.data.configuration); + + if (this.down("#configurationCards").getLayout().getActiveItem().reconfigureColumns !== null) { + this.down("#configurationCards").getLayout().getActiveItem().reconfigureColumns( + this.importColumnsStore); + } + } + }, + customFieldIgnorer: function (field) + { + return !field.persist; + }, + /** + * @param {Ext.data.field.Field} The model + */ + appendFieldData: function (field, node) + { + var fieldData = {}; + fieldData.data = node.get("data"); + + if (!node.parentNode.data.data.hasOwnProperty("configuration")) { + node.parentNode.data.data.configuration = {}; + } + + if (!node.parentNode.data.data.configuration.hasOwnProperty("fields")) { + node.parentNode.data.data.configuration.fields = {}; + } + + if (!node.parentNode.data.data.configuration.hasOwnProperty("onetomany")) { + node.parentNode.data.data.configuration.onetomany = {}; + } + + if (!node.parentNode.data.data.configuration.hasOwnProperty("manytoone")) { + node.parentNode.data.data.configuration.manytoone = {}; + } + + switch (node.data.data.type) { + case "manytoone": + if (!node.parentNode.data.data.configuration.manytoone.hasOwnProperty(node.data.text)) { + node.parentNode.data.data.configuration.manytoone[node.data.text] = {}; + } + fieldData.data.configuration = node.parentNode.data.data.configuration.manytoone[node.data.text]; + + if (typeof field.reference !== "undefined" && field.reference !== null) { + fieldData.data.reference = Ext.ClassManager.get(field.reference.type); + } else { + fieldData.data.reference = this.model; + } + + if (field.allowBlank === false) { + fieldData.required = true; + } + return fieldData; + case "onetomany": + if (!node.parentNode.data.data.configuration.onetomany.hasOwnProperty(node.data.text)) { + node.parentNode.data.data.configuration.onetomany[node.data.text] = {}; + } + fieldData.data.configuration = node.parentNode.data.data.configuration.onetomany[node.data.text]; + break; + default: + + + if (!node.parentNode.data.data.configuration.fields.hasOwnProperty(node.data.text)) { + node.parentNode.data.data.configuration.fields[node.data.text] = {}; + } + fieldData.data.configuration = node.parentNode.data.data.configuration.fields[node.data.text]; + + field.compileValidators(); + + for (var i = 0; i < field._validators.length; i++) { + if (field._validators[i].type === "presence") { + fieldData.required = true; + } else { + fieldData.required = false; + } + + } + + return fieldData; + } + }, + loadData: function (temporaryFile) + { + this.temporaryFile = temporaryFile; + + Ext.Ajax.request({ + url: PartKeepr.getBasePath() + '/getSource/?file=' + temporaryFile, + success: function (response) + { + var responseData = Ext.decode(response.responseText); + + this.reconfigureGrid(responseData); + }, + scope: this + }); + } + , + reconfigureGrid: function (data) + { + var columns = []; + var fieldConfig = []; + var header = data[0]; + + this.importColumnsStore.removeAll(); + + for (var i = 0; i < header.length; i++) { + columns.push({ + text: header[i], + dataIndex: "field" + i + }); + + this.importColumnsStore.add({"headerIndex": i, "headerName": header[i]}); + + fieldConfig.push({ + name: "field" + i, + type: "string" + }); + } + + var store = Ext.create("Ext.data.Store", fieldConfig); + + var recordData = []; + for (i = 1; i < data.length; i++) { + var row = {}; + for (var j = 0; j < data[i].length; j++) { + row["field" + j] = data[i][j]; + } + + recordData.push(row); + } + + store.add(recordData); + + this.down("#sourceFileGrid").reconfigure(store, columns); + } +}); diff --git a/src/PartKeepr/FrontendBundle/Resources/public/js/Components/Importer/ImporterEntityConfiguration.js b/src/PartKeepr/FrontendBundle/Resources/public/js/Components/Importer/ImporterEntityConfiguration.js @@ -0,0 +1,128 @@ +Ext.define("PartKeepr.Importer.ImporterEntityConfiguration", { + extend: "Ext.form.Panel", + layout: { + type: 'vbox', + align: 'stretch' + }, + scrollable: 'y', + importerField: null, + xtype: 'importerEntityConfiguration', + + items: [ + { + xtype: 'radio', + boxLabel: i18n("Always import"), + name: 'importBehaviour', + inputValue: 'alwaysImport', + checked: true, + itemId: 'alwaysImport' + }, + { + xtype: 'radio', + boxLabel: i18n("Match import data with existing data using:"), + name: 'importBehaviour', + checked: false, + inputValue: 'matchData', + itemId: 'matchData' + }, + { + disabled: true, + xtype: 'importFieldMatcherGrid', + itemId: 'importFieldMatcherGrid', + height: 100 + }, + { + xtype: 'radio', + boxLabel: i18n("Don't update data if an item exists"), + disabled: true, + inputValue: 'dontUpdate', + itemId: 'dontUpdateData', + name: 'updateBehaviour' + }, + { + xtype: 'radio', + boxLabel: i18n("Update data if an item exists"), + disabled: true, + checked: true, + inputValue: 'update', + itemId: 'updateData', + name: 'updateBehaviour' + } + ], + initComponent: function () + { + this.defaults = { + listeners: { + change: function () + { + this.fireEvent("configChanged"); + }, + scope: this + } + }; + + this.callParent(arguments); + + this.down("#alwaysImport").on("change", this.onImportBehaviourChange, this); + this.down("#matchData").on("change", this.onImportBehaviourChange, this); + }, + onImportBehaviourChange: function () + { + var fieldValues = this.getForm().getFieldValues(); + + if (fieldValues.importBehaviour === "matchData") { + this.down("#importFieldMatcherGrid").enable(); + this.down("#dontUpdateData").enable(); + this.down("#updateData").enable(); + } else { + this.down("#importFieldMatcherGrid").disable(); + this.down("#dontUpdateData").disable(); + this.down("#updateData").disable(); + } + + Ext.apply(this.importerConfig, this.getImporterConfig()); + }, + setModel: function (model) + { + this.down("importFieldMatcherGrid").setModel(model); + + }, + getImporterConfig: function () + { + var config = this.getForm().getFieldValues(); + + config.matchers = this.down("#importFieldMatcherGrid").getImporterConfig(); + return config; + }, + setImporterConfig: function (config) + { + this.importerConfig = config; + + this.getForm().setValues(this.importerConfig); + + if (this.importerConfig === {}) { + this.getForm().reset(); + this.down("#importFieldMatcherGrid").setImporterConfig({}); + return; + } + + if (config.hasOwnProperty("matchers")) { + this.down("#importFieldMatcherGrid").setImporterConfig(this.importerConfig.matchers); + } else { + this.down("#importFieldMatcherGrid").setImporterConfig({}); + } + }, + reconfigureColumns: function (columnsStore) + { + this.down("#importFieldMatcherGrid").reconfigureColumns(columnsStore); + }, + setImporterField: function (field) + { + this.importerField = field; + }, + getImporterField: function () + { + return this.importerField; + } + +}); diff --git a/src/PartKeepr/FrontendBundle/Resources/public/js/Components/Importer/ImporterFieldConfiguration.js b/src/PartKeepr/FrontendBundle/Resources/public/js/Components/Importer/ImporterFieldConfiguration.js @@ -0,0 +1,140 @@ +Ext.define("PartKeepr.Importer.ImporterFieldConfiguration", { + extend: "Ext.form.Panel", + layout: 'vbox', + scrollable: 'y', + xtype: 'importerFieldConfiguration', + importerField: null, + defaultListenerScope: true, + defaultConfig: { + fieldConfiguration: "ignore", + copyFromField: "", + setToValue: "" + }, + loading: false, + items: [ + { + xtype: 'radio', + boxLabel: i18n("Ignore"), + itemId: "ignore", + name: "fieldConfiguration", + inputValue: 'ignore', + checked: true, + listeners: { + change: "onChange" + } + }, + + { + xtype: 'fieldcontainer', + layout: { + type: 'hbox', + align: "stretch" + }, + items: [ + { + xtype: 'radio', + boxLabel: i18n("Copy contents from:"), + itemId: "copyContentsFrom", + name: "fieldConfiguration", + inputValue: 'copyFrom', + listeners: { + change: "onChange" + } + }, + { + xtype: 'combo', + forceSelection: true, + editable: false, + itemId: 'importFieldSelector', + name: 'copyFromField', + queryMode: "local", + displayField: "headerName", + valueField: "headerIndex", + listeners: { + change: "onChange" + } + }, + ] + }, + { + xtype: 'fieldcontainer', + layout: { + type: 'hbox', + align: "stretch" + }, + items: [ + { + xtype: 'radio', + boxLabel: i18n("Set to fixed value"), + itemId: "setToFixedValue", + name: "fieldConfiguration", + inputValue: 'fixedValue', + listeners: { + change: "onChange" + } + }, + { + xtype: 'textfield', + itemId: "fixedValue", + name: "setToValue", + disabled: true, + listeners: { + change: "onChange" + } + } + ] + } + ], + initComponent: function () + { + this.callParent(arguments); + this.down("#copyContentsFrom").on("change", this.onFieldConfigurationChange, this); + this.down("#setToFixedValue").on("change", this.onFieldConfigurationChange, this); + }, + onChange: function () + { + this.onFieldConfigurationChange(); + + if (!this.loading) { + Ext.apply(this.importerConfig, this.getForm().getValues()); + } + this.fireEvent("configChanged"); + + }, + onFieldConfigurationChange: function () + { + var fieldValues = this.getForm().getFieldValues(); + + switch (fieldValues.fieldConfiguration) { + case "copyFrom": + this.down("#importFieldSelector").enable(); + this.down("#fixedValue").disable(); + break; + case "fixedValue": + this.down("#importFieldSelector").disable(); + this.down("#fixedValue").enable(); + break; + default: + this.down("#importFieldSelector").disable(); + this.down("#fixedValue").disable(); + } + }, + setModel: function (model) + { + this.down("importFieldMatcherGrid").setModel(model); + }, + setImporterConfig: function (config) + { + this.importerConfig = config; + Ext.applyIf(this.importerConfig, this.defaultConfig); + this.loading = true; + this.getForm().setValues(this.importerConfig); + this.loading = false; + + this.onFieldConfigurationChange(); + }, + reconfigureColumns: function (columnsStore) + { + this.down("#importFieldSelector").setStore(columnsStore); + } +}); diff --git a/src/PartKeepr/FrontendBundle/Resources/public/js/Components/Importer/ImporterManyToOneConfiguration.js b/src/PartKeepr/FrontendBundle/Resources/public/js/Components/Importer/ImporterManyToOneConfiguration.js @@ -0,0 +1,360 @@ +Ext.define("PartKeepr.Importer.ImporterManyToOneConfiguration", { + extend: "Ext.form.Panel", + layout: { + type: 'vbox', + align: 'stretch' + }, + scrollable: 'y', + importerField: null, + xtype: 'importerManyToOneConfiguration', + model: null, + defaultListenerScope: true, + loading: false, + defaultConfig: { + importBehaviour: "dontSet", + updateBehaviour: "dontUpdate", + notFoundBehaviour: "stopImport", + setToEntity: "", + notFoundSetToEntity: "", + matchers: [], + }, + items: [ + { + xtype: 'radio', + boxLabel: i18n("Don't set"), + name: 'importBehaviour', + inputValue: 'dontSet', + checked: true, + itemId: 'dontSet', + listeners: { + change: "onChange" + } + }, + { + xtype: 'fieldcontainer', + layout: { + type: 'hbox' + }, + items: [ + + { + xtype: 'radio', + boxLabel: i18n("Always set to:"), + name: 'importBehaviour', + inputValue: 'alwaysSetTo', + checked: false, + itemId: 'alwaysSetTo', + listeners: { + change: "onChange" + } + }, { + xtype: 'textfield', + itemId: 'entity', + name: 'setToEntity', + readOnly: true, + listeners: { + change: "onChange" + } + }, { + xtype: 'button', + text: i18n("Select entity…"), + itemId: 'selectEntity' + } + ] + }, + { + xtype: 'radio', + boxLabel: i18n("Match import data with existing data using:"), + name: 'importBehaviour', + checked: false, + inputValue: 'matchData', + itemId: 'matchData', + listeners: { + change: "onChange" + } + }, + { + xtype: 'fieldcontainer', + layout: { + type: 'vbox', + align: 'stretch' + }, + margin: { + left: 20, + }, + items: [ + { + disabled: true, + xtype: 'importFieldMatcherGrid', + itemId: 'importFieldMatcherGrid', + height: 100, + listeners: { + change: "onChange" + } + }, + { + xtype: 'fieldset', + title: i18n("Behaviour when item exists"), + items: [ + { + + xtype: 'radio', + boxLabel: i18n("Don't update data if an item exists"), + disabled: true, + inputValue: 'dontUpdate', + itemId: 'dontUpdateData', + name: 'updateBehaviour', + listeners: { + change: "onChange" + } + }, + { + xtype: 'radio', + boxLabel: i18n("Update data if an item exists"), + disabled: true, + checked: true, + inputValue: 'update', + itemId: 'updateData', + name: 'updateBehaviour', listeners: { + change: "onChange" + } + + + } + ] + }, + { + xtype: 'fieldset', + title: i18n("Behaviour when item does not exist"), + items: [ + { + xtype: 'radio', + boxLabel: i18n("Stop import"), + disabled: true, + inputValue: 'stopImport', + itemId: 'stopImport', + checked: true, + name: 'notFoundBehaviour', + listeners: { + change: "onChange" + } + }, + { + xtype: 'radio', + boxLabel: i18n("Create new entity"), + disabled: true, + inputValue: 'createEntity', + itemId: 'createEntity', + checked: false, + name: 'notFoundBehaviour', + listeners: { + change: "onChange" + } + }, + { + xtype: 'fieldcontainer', + layout: { + type: 'hbox' + }, + items: [ + { + xtype: 'radio', + boxLabel: i18n("Set to:"), + name: 'notFoundBehaviour', + inputValue: 'setToEntity', + disabled: true, + checked: false, + itemId: 'setTo', + listeners: { + change: "onChange" + } + }, { + xtype: 'textfield', + itemId: 'notFoundSetToEntity', + name: 'notFoundSetToEntity', + disabled: true, + readOnly: true, + listeners: { + change: "onChange" + } + }, { + xtype: 'button', + text: i18n("Select entity…"), + disabled: true, + itemId: 'notFoundSelectEntity' + } + ] + }, + ] + } + + ] + } + ], + initComponent: function () + { + this.callParent(arguments); + + var importBehaviourChangeListeners = [ + "#alwaysSetTo", + "#matchData", + "#dontUpdateData", + "#updateData", + "#setTo", + "#stopImport" + ]; + + for (var i = 0; i < importBehaviourChangeListeners.length; i++) { + this.down(importBehaviourChangeListeners[i]).on("change", this.onImportBehaviourChange, this, {delay: 50}); + } + this.down("#selectEntity").on("click", this.onEntitySelectClick, this); + this.down("#notFoundSelectEntity").on("click", this.onEntityNotFoundSelectClick, this); + + }, + onChange: function () + { + this.onImportBehaviourChange(); + + + this.fireEvent("configChanged"); + if (!this.loading) { + Ext.apply(this.importerConfig, this.getImporterConfig()); + } + }, + onEntitySelectClick: function () + { + this.entitySelector = Ext.create("Ext.window.Window", { + items: Ext.create("PartKeepr.Widgets.EntityPicker", { + model: this.model, + listeners: { + entityselect: this.onEntitySelect, + scope: this + }, + ittemId: "entitySelectorPanel" + }), + title: i18n("Select entity"), + width: "80%", + height: "80%", + modal: true, + layout: 'fit', + maximizable: true, + closeAction: 'destroy' + }); + + this.entitySelector.show(); + }, + onEntityNotFoundSelectClick: function () + { + this.entitySelector = Ext.create("Ext.window.Window", { + items: Ext.create("PartKeepr.Widgets.EntityPicker", { + model: this.model, + listeners: { + entityselect: this.onEntityNotFoundSelect, + scope: this + }, + ittemId: "entitySelectorPanel" + }), + title: i18n("Select entity"), + width: "80%", + height: "80%", + modal: true, + layout: 'fit', + maximizable: true, + closeAction: 'destroy' + }); + + this.entitySelector.show(); + }, + onEntitySelect: function (entity) + { + + this.down("#entity").setValue(entity.getId()); + this.entitySelector.destroy(); + }, + onEntityNotFoundSelect: function (entity) + { + + this.down("#notFoundSetToEntity").setValue(entity.getId()); + this.entitySelector.destroy(); + }, + onImportBehaviourChange: function () + { + var fieldValues = this.getForm().getFieldValues(); + + switch (fieldValues.importBehaviour) { + case "matchData": + this.down("#importFieldMatcherGrid").enable(); + this.down("#dontUpdateData").enable(); + this.down("#updateData").enable(); + this.down("#entity").disable(); + this.down("#selectEntity").disable(); + this.down("#stopImport").enable(); + this.down("#createEntity").enable(); + this.down("#setTo").enable(); + + if (fieldValues.notFoundBehaviour === "setToEntity") { + this.down("#notFoundSetToEntity").enable(); + this.down("#notFoundSelectEntity").enable(); + } else { + this.down("#notFoundSetToEntity").disable(); + this.down("#notFoundSelectEntity").disable(); + } + break; + case "alwaysSetTo": + this.down("#selectEntity").enable(); + this.down("#createEntity").disable(); + this.down("#importFieldMatcherGrid").disable(); + this.down("#dontUpdateData").disable(); + this.down("#entity").enable(); + this.down("#updateData").disable(); + this.down("#setTo").disable(); + this.down("#stopImport").disable(); + break; + default: + this.down("#selectEntity").disable(); + this.down("#createEntity").disable(); + this.down("#importFieldMatcherGrid").disable(); + this.down("#dontUpdateData").disable(); + this.down("#entity").disable(); + this.down("#updateData").disable(); + this.down("#setTo").disable(); + this.down("#stopImport").disable(); + + } + + }, + setModel: function (model) + { + this.down("#importFieldMatcherGrid").setModel(model); + this.model = model; + + }, + getImporterConfig: function () + { + var config = this.getForm().getFieldValues(); + + config.matchers = this.down("#importFieldMatcherGrid").getImporterConfig(); + return config; + }, + reconfigureColumns: function (columnsStore) + { + this.down("#importFieldMatcherGrid").reconfigureColumns(columnsStore); + }, + setImporterConfig: function (config) + { + this.importerConfig = config; + Ext.applyIf(this.importerConfig, this.defaultConfig); + + this.loading = true; + this.getForm().setValues(this.importerConfig); + + if (this.importerConfig.hasOwnProperty("matchers")) { + this.down("#importFieldMatcherGrid").setImporterConfig(this.importerConfig.matchers); + } else { + this.down("#importFieldMatcherGrid").setImporterConfig({}); + } + + this.loading = false; + } + +}); diff --git a/src/PartKeepr/FrontendBundle/Resources/public/js/Components/Importer/ImporterOneToManyConfiguration.js b/src/PartKeepr/FrontendBundle/Resources/public/js/Components/Importer/ImporterOneToManyConfiguration.js @@ -0,0 +1,278 @@ +Ext.define("PartKeepr.Importer.ImporterOneToManyConfiguration", { + extend: "Ext.form.Panel", + layout: { + type: 'vbox', + align: 'stretch' + }, + scrollable: 'y', + importerField: null, + xtype: 'importerOneToManyConfiguration', + model: null, + defaultListenerScope: true, + defaultConfig: { + importBehaviour: "ignore", + updateBehaviour: "dontUpdate", + notFoundBehaviour: "stopImport", + notFoundSetToEntity: "", + matchers: [], + }, + items: [ + { + xtype: 'radio', + boxLabel: i18n("Don't import this sub-entity"), + name: 'importBehaviour', + inputValue: 'ignore', + checked: true, + itemId: 'ignore', + listeners: { + change: "onChange" + } + }, + { + xtype: 'radio', + boxLabel: i18n("Match import data with existing data using:"), + name: 'importBehaviour', + checked: false, + inputValue: 'matchData', + itemId: 'matchData', + listeners: { + change: "onChange" + } + }, + { + xtype: 'fieldcontainer', + layout: { + type: 'vbox', + align: 'stretch' + }, + margin: { + left: 20, + }, + items: [ + { + disabled: true, + xtype: 'importFieldMatcherGrid', + itemId: 'importFieldMatcherGrid', + height: 100, + listeners: { + change: "onChange" + } + }, + { + xtype: 'fieldset', + title: i18n("Behaviour when item exists"), + items: [ + { + + xtype: 'radio', + boxLabel: i18n("Don't update data if an item exists"), + disabled: true, + inputValue: 'dontUpdate', + itemId: 'dontUpdateData', + name: 'updateBehaviour', + listeners: { + change: "onChange" + } + }, + { + xtype: 'radio', + boxLabel: i18n("Update data if an item exists"), + disabled: true, + checked: true, + inputValue: 'update', + itemId: 'updateData', + name: 'updateBehaviour', + listeners: { + change: "onChange" + } + + + } + ] + }, + { + xtype: 'fieldset', + title: i18n("Behaviour when item does not exist"), + items: [ + { + xtype: 'radio', + boxLabel: i18n("Stop import"), + disabled: true, + inputValue: 'stopImport', + itemId: 'stopImport', + checked: true, + name: 'notFoundBehaviour', + listeners: { + change: "onChange" + } + }, + { + xtype: 'radio', + boxLabel: i18n("Create new entity"), + disabled: true, + inputValue: 'createEntity', + itemId: 'createEntity', + checked: false, + name: 'notFoundBehaviour', + listeners: { + change: "onChange" + } + }, + { + xtype: 'fieldcontainer', + layout: { + type: 'hbox' + }, + items: [ + { + xtype: 'radio', + boxLabel: i18n("Set to:"), + name: 'notFoundBehaviour', + inputValue: 'setToEntity', + disabled: true, + checked: false, + itemId: 'setTo', + listeners: { + change: "onChange" + } + }, { + xtype: 'textfield', + itemId: 'notFoundSetToEntity', + name: 'notFoundSetToEntity', + disabled: true, + readOnly: true, + listeners: { + change: "onChange" + } + }, { + xtype: 'button', + text: i18n("Select entity…"), + disabled: true, + itemId: 'notFoundSelectEntity' + } + ] + }, + ] + } + + ] + } + ], + initComponent: function () + { + this.callParent(arguments); + + var importBehaviourChangeListeners = ["#matchData", "#dontUpdateData", "#updateData", "#setTo", "#stopImport"]; + + for (var i = 0; i < importBehaviourChangeListeners.length; i++) { + this.down(importBehaviourChangeListeners[i]).on("change", this.onImportBehaviourChange, this, {delay: 50}); + } + + this.down("#notFoundSelectEntity").on("click", this.onEntitySelectClick, this); + + }, + onEntitySelectClick: function () + { + this.entitySelector = Ext.create("Ext.window.Window", { + items: Ext.create("PartKeepr.Widgets.EntityPicker", { + model: this.model, + listeners: { + entityselect: this.onEntitySelect, + scope: this + }, + ittemId: "entitySelectorPanel" + }), + title: i18n("Select entity"), + width: "80%", + height: "80%", + modal: true, + layout: 'fit', + maximizable: true, + closeAction: 'destroy' + }); + + this.entitySelector.show(); + }, + onEntitySelect: function (entity) + { + + this.down("#notFoundSetToEntity").setValue(entity.getId()); + this.entitySelector.destroy(); + }, + onChange: function () + { + this.onImportBehaviourChange(); + this.fireEvent("configChanged"); + + if (!this.loading) { + Ext.apply(this.importerConfig, this.getImporterConfig()); + } + }, + onImportBehaviourChange: function () + { + var fieldValues = this.getForm().getFieldValues(); + + if (fieldValues.importBehaviour === "matchData") { + this.down("#importFieldMatcherGrid").enable(); + this.down("#dontUpdateData").enable(); + this.down("#updateData").enable(); + this.down("#stopImport").enable(); + this.down("#createEntity").enable(); + this.down("#setTo").enable(); + + if (fieldValues.notFoundBehaviour === "setToEntity") { + this.down("#notFoundSetToEntity").enable(); + this.down("#notFoundSelectEntity").enable(); + } else { + this.down("#notFoundSetToEntity").disable(); + this.down("#notFoundSelectEntity").disable(); + } + } else { + this.down("#createEntity").disable(); + this.down("#importFieldMatcherGrid").disable(); + this.down("#dontUpdateData").disable(); + this.down("#updateData").disable(); + this.down("#setTo").disable(); + this.down("#stopImport").disable(); + } + }, + setModel: function (model, ignoreModel) + { + var ignoreModelName = null; + if (ignoreModel !== undefined) { + ignoreModelName = ignoreModel.getName(); + } + + this.down("#importFieldMatcherGrid").setModel(model, ignoreModelName); + this.model = model; + + }, + getImporterConfig: function () + { + var config = this.getForm().getFieldValues(); + + config.matchers = this.down("#importFieldMatcherGrid").getImporterConfig(); + return config; + }, + reconfigureColumns: function (columnsStore) + { + this.down("#importFieldMatcherGrid").reconfigureColumns(columnsStore); + }, + setImporterConfig: function (config) + { + this.importerConfig = config; + Ext.applyIf(this.importerConfig, this.defaultConfig); + + this.loading = true; + this.getForm().setValues(this.importerConfig); + + if (this.importerConfig.hasOwnProperty("matchers")) { + this.down("#importFieldMatcherGrid").setImporterConfig(this.importerConfig.matchers); + } else { + this.down("#importFieldMatcherGrid").setImporterConfig({}); + } + + this.loading = false; + } + +}); diff --git a/src/PartKeepr/FrontendBundle/Resources/public/js/Components/ModelTreeMaker/ModelTreeMaker.js b/src/PartKeepr/FrontendBundle/Resources/public/js/Components/ModelTreeMaker/ModelTreeMaker.js @@ -0,0 +1,136 @@ +/** + * Creates a tree of nodes for a given model + */ +Ext.define("PartKeepr.ModelTreeMaker.ModelTreeMaker", { + /** + * @var {Array} Contains the models already in the field tree + */ + visitedModels: [], + + /** + * @var {Array} Field names which should be ignored. + */ + ignoreFields: [], + + customFieldIgnorer: Ext.emptyFn, + + constructor: function () + { + this.visitedModels = []; + }, + + /** + * Adds a field to be ignored. + * + * @param {String} The field to be ignored. + */ + addIgnoreField: function (field) + { + this.ignoreFields.push(field); + }, + setCustomFieldIgnorer: function (customIgnorer) + { + this.customFieldIgnorer = customIgnorer; + }, + /** + * 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 + */ + make: function (node, model, prefix, callback) + { + var newNode,i ,j, childNode, associationAlreadyProcessed; + + if (!prefix) { + prefix = ""; + } + + if (!callback) { + callback = null; + } + + var fields = model.getFields(); + + this.visitedModels.push(model.getName()); + + for (i = 0; i < fields.length; i++) { + if (fields[i]["$reference"] === undefined) { + // Field is a scalar field + if (this.ignoreFields.indexOf(fields[i].name) === -1 && !this.customFieldIgnorer(fields[i])) { + newNode = node.appendChild({ + text: fields[i].name, + leaf: true, + data: { + name: prefix + fields[i].name, + type: "field" + } + }); + + if (callback) { + newNode.set(callback(fields[i], newNode)); + } + } + } else { + // Field is an association; recurse into associations + associationAlreadyProcessed = false; + for (j = 0; j < this.visitedModels.length; j++) { + if (this.visitedModels[j] === fields[i].reference.cls.getName()) { + // The association was already processed; skip return + associationAlreadyProcessed = true; + } + } + + if (!associationAlreadyProcessed) { + childNode = node.appendChild({ + text: fields[i].name, + data: { + name: prefix + fields[i].name, + type: "manytoone" + }, + leaf: false + }); + + if (callback) { + childNode.set(callback(fields[i], childNode)); + } + + this.make(childNode, fields[i].reference.cls, prefix + fields[i].name + ".", callback); + } + } + } + + var associations = model.associations; + + + for (i in associations) { + associationAlreadyProcessed = false; + if (typeof associations[i].legacy !== "undefined" && associations[i].isMany === true) { + for (j = 0; j < this.visitedModels.length; j++) { + if (this.visitedModels[j] === associations[i].model) { + associationAlreadyProcessed = true; + } + } + + if (!associationAlreadyProcessed) { + childNode = node.appendChild({ + text: associations[i].name, + data: { + name: prefix + associations[i].name, + type: "onetomany", + reference: associations[i].cls + }, + leaf: false + }); + + if (callback) { + childNode.set(callback(associations[i].cls, childNode)); + } + + this.make(childNode, associations[i].cls, prefix + associations[i].name + ".", callback); + } + } + } + } +}); diff --git a/src/PartKeepr/FrontendBundle/Resources/public/js/Components/Project/ProjectPartGrid.js b/src/PartKeepr/FrontendBundle/Resources/public/js/Components/Project/ProjectPartGrid.js @@ -97,7 +97,14 @@ Ext.define('PartKeepr.ProjectPartGrid', { tooltip: i18n("Export"), iconCls: "fugue-icon application-export", disabled: this.store.isLoading() - }) + }), + Ext.create("PartKeepr.Importer.GridImporterButton", { + itemId: 'import', + tooltip: i18n("Import"), + iconCls: "fugue-icon database-import", + disabled: this.store.isLoading() + }), + ]; this.callParent(); diff --git a/src/PartKeepr/FrontendBundle/Resources/public/js/Components/Widgets/EntityPicker.js b/src/PartKeepr/FrontendBundle/Resources/public/js/Components/Widgets/EntityPicker.js @@ -0,0 +1,39 @@ +Ext.define("PartKeepr.Widgets.EntityPicker", { + extend: "PartKeepr.Widgets.EntityQueryPanel", + + initComponent: function () + { + this.callParent(arguments); + + var bottomToolbar = Ext.create("Ext.toolbar.Paging", { + store: this.down("#grid").store, + enableOverflow: true, + dock: 'bottom', + displayInfo: false + }); + + bottomToolbar.insert(0, [{ + xtype: 'button', + text: i18n("Select entity"), + itemId: "selectEntity", + disabled: true, + handler: this.onEntitySelect, + scope: this + }, '-']); + + + this.down("#grid").addDocked(bottomToolbar); + this.down("#grid").on("selectionchange", this.onSelectionChange, this); + this.down("#grid").on("itemdblclick", this.onEntitySelect, this); + }, + onSelectionChange: function (grid, selected) { + if (selected.length != 1) { + this.down("#selectEntity").disable(); + } else { + this.down("#selectEntity").enable(); + } + }, + onEntitySelect: function () { + this.fireEvent("entityselect", this.down("#grid").getSelection()[0]); + }, +}); diff --git a/src/PartKeepr/FrontendBundle/Resources/public/js/Components/Widgets/EntityQueryPanel.js b/src/PartKeepr/FrontendBundle/Resources/public/js/Components/Widgets/EntityQueryPanel.js @@ -0,0 +1,269 @@ +Ext.define("PartKeepr.Widgets.EntityQueryPanel", { + extend: "Ext.panel.Panel", + layout: 'border', + items: [ + { + title: i18n("Results"), + 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 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.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); + }, + /** + * 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 (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) + { + 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, i; + 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: this.columnRenderer, + scope: this.down('#grid') + }); + } + }, + columnRenderer: function (value, metadata, record, rowIndex, colIndex) + { + return record.get(this.getColumns()[colIndex].dataIndex); + }, + /** + * 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 i, 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/Widgets/FieldSelector.js b/src/PartKeepr/FrontendBundle/Resources/public/js/Components/Widgets/FieldSelector.js @@ -36,6 +36,13 @@ Ext.define('PartKeepr.Components.Widgets.FieldSelector', { */ excludeFields: [], + /** + * @var {Array} An array which excludes the models listed + */ + excludeModels: [], + + useCheckBoxes: true, + initComponent: function () { this.callParent(arguments); @@ -53,10 +60,13 @@ Ext.define('PartKeepr.Components.Widgets.FieldSelector', { * @param {Ext.data.Model} The model * @param {String} The prefix. Omit if first called */ - treeMaker: function (node, model, prefix) + treeMaker: function (node, model, prefix, callback) { var fields = model.getFields(); var checked; + var newNode; + var j, childNode; + var skipSubModel = false, associationAlreadyProcessed; this.visitedModels.push(model.getName()); for (var i = 0; i < fields.length; i++) { @@ -69,31 +79,83 @@ Ext.define('PartKeepr.Components.Widgets.FieldSelector', { } if (!Ext.Array.contains(this.excludeFields, prefix + fields[i].name)) { - node.appendChild({ + newNode = { text: fields[i].name, leaf: true, - checked: checked, - data: prefix + fields[i].name - }); + data: { + name: prefix + fields[i].name, + type: "field" + }, + }; + + if (this.useCheckBoxes) { + newNode.checked = checked; + } + node.appendChild(newNode); } } else { if (this.recurseSubModels) { - for (var j = 0; j < this.visitedModels.length; j++) { + skipSubModel = false; + for (j = 0; j < this.visitedModels.length; j++) { if (this.visitedModels[j] === fields[i].reference.cls.getName()) { - return; + skipSubModel = true; } } - var childNode = node.appendChild({ - text: fields[i].name, - expanded: true, + for (j = 0; j < this.excludeModels.length; j++) { + if (this.excludeModels[j] === fields[i].reference.cls.getName()) { + skipSubModel = true; + } + } + + if (skipSubModel === false) { + childNode = node.appendChild({ + text: fields[i].name, + expanded: true, + data: { + name: prefix + fields[i].name, + type: "relation" + }, + leaf: false + }); + + this.treeMaker(childNode, fields[i].reference.cls, prefix + fields[i].name + "."); + } + } + } + + } + + var associations = model.associations; + + + for (i in associations) { + associationAlreadyProcessed = false; + if (typeof associations[i].legacy !== "undefined" && associations[i].isMany === true) { + for (j = 0; j < this.visitedModels.length; j++) { + if (this.visitedModels[j] === associations[i].model) { + associationAlreadyProcessed = true; + } + } + + if (!associationAlreadyProcessed) { + childNode = node.appendChild({ + text: associations[i].name, + data: { + name: prefix + associations[i].name, + type: "onetomany", + reference: associations[i].cls + }, leaf: false }); - this.treeMaker(childNode, fields[i].reference.cls, prefix + fields[i].name + "."); + if (callback !== undefined) { + childNode.set(callback(associations[i].cls, childNode)); + } + + this.treeMaker(childNode, associations[i].cls, prefix + associations[i].name + ".", callback); } } - } } }); diff --git a/src/PartKeepr/FrontendBundle/Resources/public/js/Components/Widgets/PagingToolbar.js b/src/PartKeepr/FrontendBundle/Resources/public/js/Components/Widgets/PagingToolbar.js @@ -14,6 +14,13 @@ Ext.define("PartKeepr.PagingToolbar", { disabled: this.store.isLoading() })); + items.push(Ext.create("PartKeepr.Importer.GridImporterButton", { + itemId: 'import', + tooltip: i18n("Import"), + iconCls: "fugue-icon database-import", + disabled: this.store.isLoading() + })); + items.push(Ext.create({ itemId: 'filter', xtype: 'button', diff --git a/src/PartKeepr/FrontendBundle/Resources/views/index.html.twig b/src/PartKeepr/FrontendBundle/Resources/views/index.html.twig @@ -78,8 +78,18 @@ {% javascripts output='js/compiled/main2.js' '@PartKeeprFrontendBundle/Resources/public/js/Util/i18n.js' + '@PartKeeprFrontendBundle/Resources/public/js/Components/Widgets/EntityQueryPanel.js' + '@PartKeeprFrontendBundle/Resources/public/js/Components/Widgets/EntityPicker.js' '@PartKeeprFrontendBundle/Resources/public/js/Components/Exporter/GridExporter.js' '@PartKeeprFrontendBundle/Resources/public/js/Components/Exporter/GridExporterButton.js' + '@PartKeeprFrontendBundle/Resources/public/js/Components/Importer/GridImporterButton.js' + '@PartKeeprFrontendBundle/Resources/public/js/Components/Importer/Importer.js' + '@PartKeeprFrontendBundle/Resources/public/js/Components/Importer/ImporterEntityConfiguration.js' + '@PartKeeprFrontendBundle/Resources/public/js/Components/Importer/ImporterOneToManyConfiguration.js' + '@PartKeeprFrontendBundle/Resources/public/js/Components/Importer/ImporterManyToOneConfiguration.js' + '@PartKeeprFrontendBundle/Resources/public/js/Components/Importer/ImporterFieldConfiguration.js' + '@PartKeeprFrontendBundle/Resources/public/js/Components/Importer/ImportFieldMatcherGrid.js' + '@PartKeeprFrontendBundle/Resources/public/js/Components/ModelTreeMaker/ModelTreeMaker.js' '@PartKeeprFrontendBundle/Resources/public/js/Util/Blob.js' '@PartKeeprFrontendBundle/Resources/public/js/Util/FileSaver.js' '@PartKeeprFrontendBundle/Resources/public/js/Components/Widgets/PagingToolbar.js' diff --git a/src/PartKeepr/ImportBundle/Configuration/BaseConfiguration.php b/src/PartKeepr/ImportBundle/Configuration/BaseConfiguration.php @@ -0,0 +1,40 @@ +<?php + + +namespace PartKeepr\ImportBundle\Configuration; + + +use Doctrine\Common\Persistence\Mapping\ClassMetadata; +use Doctrine\ORM\EntityManager; +use Dunglas\ApiBundle\Api\IriConverter; +use PartKeepr\DoctrineReflectionBundle\Filter\AdvancedSearchFilter; +use PartKeepr\DoctrineReflectionBundle\Services\ReflectionService; + +class BaseConfiguration +{ + protected $baseEntity; + + protected $classMetadata; + + protected $reflectionService; + + protected $em; + + protected $advancedSearchFilter; + + protected $iriConverter; + + public function __construct(ClassMetadata $classMetadata, $baseEntity, ReflectionService $reflectionService, EntityManager $em, AdvancedSearchFilter $advancedSearchFilter, IriConverter $iriConverter) + { + $this->classMetadata = $classMetadata; + $this->baseEntity = $baseEntity; + $this->reflectionService = $reflectionService; + $this->em = $em; + $this->advancedSearchFilter = $advancedSearchFilter; + $this->iriConverter = $iriConverter; + } + + public function import ($row) { + + } +} diff --git a/src/PartKeepr/ImportBundle/Configuration/Configuration.php b/src/PartKeepr/ImportBundle/Configuration/Configuration.php @@ -0,0 +1,86 @@ +<?php +namespace PartKeepr\ImportBundle\Configuration; + +use Symfony\Component\PropertyAccess\PropertyAccess; + + +class Configuration extends BaseConfiguration +{ + /** + * @var FieldConfiguration[] + */ + private $fields = []; + + /** + * @var ManyToOneConfiguration[] + */ + private $manyToOneAssociations = []; + + public function parseConfiguration($importConfiguration) + { + if (property_exists($importConfiguration, "fields")) { + foreach ($importConfiguration->fields as $field => $configuration) { + if ($this->classMetadata->hasField($field)) { + + $fieldConfiguration = new FieldConfiguration($this->classMetadata, $this->baseEntity, + $this->reflectionService, $this->em, $this->advancedSearchFilter, $this->iriConverter); + $fieldConfiguration->setFieldName($field); + if ($fieldConfiguration->parseConfiguration($configuration) !== false) { + $this->fields[] = $fieldConfiguration; + } + } else { + throw new \Exception("Field $field not found in ".$this->baseEntity); + } + } + } + + if (property_exists($importConfiguration, "manytoone")) { + foreach ($importConfiguration->manytoone as $manyToOne => $configuration) { + if ($this->classMetadata->hasAssociation($manyToOne)) { + $targetClass = $this->classMetadata->getAssociationTargetClass($manyToOne); + $cm = $this->em->getClassMetadata($targetClass); + $manyToOneconfiguration = new ManyToOneConfiguration($cm, $targetClass, + $this->reflectionService, $this->em, $this->advancedSearchFilter, $this->iriConverter); + $manyToOneconfiguration->setAssociationName($manyToOne); + + if ($manyToOneconfiguration->parseConfiguration($configuration) !== false) { + $this->manyToOneAssociations[] = $manyToOneconfiguration; + } + } else { + throw new \Exception("Association $manyToOne not found in ".$this->baseEntity); + } + } + } + + return true; + } + + public function import ($row) { + $logs = []; + + $obj = new $this->baseEntity; + $accessor = PropertyAccess::createPropertyAccessor(); + + foreach ($this->fields as $field) { + $name = $field->getFieldName(); + list($data, $log) = $field->import($row); + + $logs[] = $log; + + if ($data !== null) { + $accessor->setValue($obj, $name, $data); + } + } + + foreach ($this->manyToOneAssociations as $manyToOneAssociation) { + $name = $manyToOneAssociation->getAssociationName(); + list($data, $log) = $manyToOneAssociation->import($row); + $logs[] = $log; + if ($data !== null) { + $accessor->setValue($obj, $name, $data); + } + } + + return [$obj, $logs]; + } +} diff --git a/src/PartKeepr/ImportBundle/Configuration/FieldConfiguration.php b/src/PartKeepr/ImportBundle/Configuration/FieldConfiguration.php @@ -0,0 +1,88 @@ +<?php +namespace PartKeepr\ImportBundle\Configuration; + + +class FieldConfiguration extends BaseConfiguration +{ + const FIELDCONFIGURATION_IGNORE = "ignore"; + const FIELDCONFIGURATION_COPYFROM = "copyFrom"; + const FIELDCONFIGURATION_FIXEDVALUE = "fixedValue"; + + const fieldConfigurationModes = [ + self::FIELDCONFIGURATION_COPYFROM, + self::FIELDCONFIGURATION_FIXEDVALUE, + self::FIELDCONFIGURATION_IGNORE, + ]; + + private $fieldConfiguration; + + private $fieldName; + + /** + * @return mixed + */ + public function getFieldName() + { + return $this->fieldName; + } + + private $fixedValue; + + private $copyFromField; + + public function setFieldName($fieldName) + { + $this->fieldName = $fieldName; + } + + public function parseConfiguration($configuration) + { + if (!property_exists($configuration, "fieldConfiguration")) { + return false; + throw new \Exception("The key fieldConfiguration does not exist!"); + } + + if (!in_array($configuration->fieldConfiguration, self::fieldConfigurationModes)) { + throw new \Exception("The key fieldConfiguration contains an invalid value!"); + } + + $this->fieldConfiguration = $configuration->fieldConfiguration; + + switch ($this->fieldConfiguration) { + case self::FIELDCONFIGURATION_FIXEDVALUE: + if (!property_exists($configuration, "setToValue")) { + throw new \Exception("The key setToValue does not exist for mode fixedValue!"); + } + + $this->fixedValue = $configuration->setToValue; + break; + case self::FIELDCONFIGURATION_COPYFROM: + if (!property_exists($configuration, "copyFromField")) { + throw new \Exception("The key copyFromField does not exist for mode copyFrom!"); + } + + $this->copyFromField = $configuration->copyFromField; + break; + default: + break; + } + + return true; + } + + public function import($row) + { + switch ($this->fieldConfiguration) { + case self::FIELDCONFIGURATION_FIXEDVALUE: + return [$this->fixedValue, + sprintf("Would set field %s to fixed value %s", $this->fieldName, $this->fixedValue)]; + break; + case self::FIELDCONFIGURATION_COPYFROM: + return [$row[$this->copyFromField], + sprintf("Would set field %s to value %s (import column %s)", $this->fieldName, $row[$this->copyFromField], $this->copyFromField)]; + break; + default: + return [null, ""]; + } + } +} diff --git a/src/PartKeepr/ImportBundle/Configuration/ManyToOneConfiguration.php b/src/PartKeepr/ImportBundle/Configuration/ManyToOneConfiguration.php @@ -0,0 +1,187 @@ +<?php +namespace PartKeepr\ImportBundle\Configuration; + + +class ManyToOneConfiguration extends Configuration +{ + const IMPORTBEHAVIOUR_DONTSET = "dontSet"; + const IMPORTBEHAVIOUR_ALWAYSSETTO = "alwaysSetTo"; + const IMPORTBEHAVIOUR_MATCHDATA = "matchData"; + + const importBehaviours = [ + self::IMPORTBEHAVIOUR_DONTSET, + self::IMPORTBEHAVIOUR_ALWAYSSETTO, + self::IMPORTBEHAVIOUR_MATCHDATA, + ]; + + const UPDATEBEHAVIOUR_DONTUPDATE = "dontUpdate"; + const UPDATEBEHAVIOUR_UPDATEDATA = "updateData"; + + const updateBehaviours = [self::UPDATEBEHAVIOUR_DONTUPDATE, self::UPDATEBEHAVIOUR_UPDATEDATA]; + + const NOTFOUNDBEHAVIOUR_STOPIMPORT = "stopImport"; + const NOTFOUNDBEHAVIOUR_SETTOENTITY = "setToEntity"; + const NOTFOUNDBEHAVIOUR_CREATEENTITY = "createEntity"; + + const notFoundBehaviours = [ + self::NOTFOUNDBEHAVIOUR_CREATEENTITY, + self::NOTFOUNDBEHAVIOUR_SETTOENTITY, + self::NOTFOUNDBEHAVIOUR_STOPIMPORT, + ]; + + protected $associationName; + + /** + * @return mixed + */ + public function getAssociationName() + { + return $this->associationName; + } + + protected $importBehaviour; + + protected $matchers = []; + + protected $notFoundBehaviour; + + protected $updateBehaviour; + + protected $notFoundSetToEntity; + + protected $setToEntity; + + /** + * @param mixed $associationName + */ + public function setAssociationName($associationName) + { + $this->associationName = $associationName; + } + + public function parseConfiguration($importConfiguration) + { + if (!property_exists($importConfiguration, "importBehaviour")) { + return false; + throw new \Exception("The key importBehaviour does not exist!"); + } + + if (!in_array($importConfiguration->importBehaviour, self::importBehaviours)) { + throw new \Exception("The key importBehaviour contains an invalid value!"); + } + + $this->importBehaviour = $importConfiguration->importBehaviour; + + switch ($this->importBehaviour) { + case self::IMPORTBEHAVIOUR_ALWAYSSETTO: + if (!property_exists($importConfiguration, "setToEntity")) { + throw new \Exception("The key setToEntity does not exist for mode alwaysSetTo!"); + } + + // @todo Check if setToEntity contains a valid value + $this->setToEntity = $importConfiguration->setToEntity; + break; + case self::IMPORTBEHAVIOUR_MATCHDATA: + if (!property_exists($importConfiguration, "matchers")) { + throw new \Exception("No matchers defined"); + } + + if (!is_array($importConfiguration->matchers)) { + throw new \Exception("matchers must be an array"); + } + + foreach ($importConfiguration->matchers as $matcher) { + if (!property_exists($matcher, "matchField") || !property_exists($matcher, + "importField") || $matcher->importField == "" + ) { + throw new \Exception("matcher configuration error"); + } + } + + $this->matchers = $importConfiguration->matchers; + + if (!property_exists($importConfiguration, "updateBehaviour")) { + throw new \Exception("The key updateBehaviour does not exist for mode copyFrom!"); + } + + if (!in_array($importConfiguration->updateBehaviour, self::updateBehaviours)) { + throw new \Exception("Invalid value for updateBehaviour"); + } + + $this->updateBehaviour = $importConfiguration->updateBehaviour; + + if (!property_exists($importConfiguration, "notFoundBehaviour")) { + throw new \Exception("The key notFoundBehaviour does not exist for mode copyFrom!"); + } + + if (!in_array($importConfiguration->notFoundBehaviour, self::notFoundBehaviours)) { + throw new \Exception("Invalid value for notFoundBehaviour"); + } + + $this->notFoundBehaviour = $importConfiguration->notFoundBehaviour; + + if ($this->notFoundBehaviour == self::NOTFOUNDBEHAVIOUR_SETTOENTITY) { + if (!property_exists($importConfiguration, "notFoundSetToEntity")) { + throw new \Exception("The key notFoundSetToEntity does not exist for mode copyFrom!"); + } + + // @todo check if notFoundSetToEntity contains a valid entity + $this->notFoundSetToEntity = $importConfiguration->notFoundSetToEntity; + } + break; + default: + break; + } + + return parent::parseConfiguration($importConfiguration); + } + + public function import($row) + { + $descriptions = []; + switch ($this->importBehaviour) { + case self::IMPORTBEHAVIOUR_ALWAYSSETTO: + $targetEntity = $this->iriConverter->getItemFromIri($this->setToEntity); + + return [$targetEntity, + sprintf("Would set %s to %s#%s", $this->associationName, $this->baseEntity, $targetEntity->getId())]; + break; + case self::IMPORTBEHAVIOUR_MATCHDATA: + $configuration = []; + + foreach ($this->matchers as $matcher) { + $foo = new \stdClass(); + $foo->property = $matcher->matchField; + $foo->operator = "="; + $foo->value = $row[$matcher->importField]; + + $descriptions[] = sprintf("%s = %s", $matcher->matchField, $row[$matcher->importField]); + $configuration[] = $foo; + } + + $configuration = $this->advancedSearchFilter->extractConfiguration($configuration, []); + + + $filters = $configuration['filters']; + $sorters = $configuration['sorters']; + $qb = new \Doctrine\ORM\QueryBuilder($this->em); + $qb->select("o")->from($this->baseEntity, "o"); + + $this->advancedSearchFilter->filter($qb, $filters, $sorters); + + try { + $result = $qb->getQuery()->getSingleResult(); + + return [$result, + sprintf("Would set %s to %s#%s", $this->associationName, $this->baseEntity, $result->getId())]; + } catch (\Exception $e) { + // @todo implement not found cases + return [null, sprintf("Would stop import as the match %s for association %s was not found", implode(",",$descriptions), $this->getAssociationName())]; + } + break; + } + + return [null, ""]; + } + +} diff --git a/src/PartKeepr/ImportBundle/Controller/ImportController.php b/src/PartKeepr/ImportBundle/Controller/ImportController.php @@ -0,0 +1,105 @@ +<?php +namespace PartKeepr\ImportBundle\Controller; + + +use Dunglas\ApiBundle\JsonLd\Response; +use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method; +use Symfony\Bundle\FrameworkBundle\Controller\Controller; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Routing\Annotation\Route; + +class ImportController extends Controller +{ + protected function detectFileFormat() + { + + } + + /** + * @Route("/getSource/") + * @return JsonResponse + */ + public function getSourceAction(Request $request) + { + $tempFileIri = $request->get("file"); + + return new JsonResponse($this->extractCSVData($tempFileIri)); + } + + /** + * @Route("/getPreview/") + * @Method({"POST"}) + * @return JsonResponse + */ + public function getPreviewAction(Request $request) + { + $tempFileIri = $request->get("file"); + + $configuration = json_decode($request->get("configuration")); + $baseEntity = $request->get("baseEntity"); + + + $data = $this->extractCSVData($tempFileIri, false); + $importService = $this->get("importer_service"); + $importService->setBaseEntity($baseEntity); + $importService->setImportConfiguration($configuration); + $importService->setImportData($data); + list($entities, $logs) = $importService->import(); + + return new JsonResponse(["logs" => $logs]); + } + + /** + * @Route("/executeImport/") + * @Method({"POST"}) + * @return JsonResponse + */ + public function importAction (Request $request) { + $tempFileIri = $request->get("file"); + + $configuration = json_decode($request->get("configuration")); + $baseEntity = $request->get("baseEntity"); + + $data = $this->extractCSVData($tempFileIri, false); + $importService = $this->get("importer_service"); + $importService->setBaseEntity($baseEntity); + $importService->setImportConfiguration($configuration); + $importService->setImportData($data); + list($entities, $logs) = $importService->import(); + + foreach ($entities as $entity) { + $this->get("doctrine")->getManager()->persist($entity); + } + + $this->get("doctrine")->getManager()->flush(); + + return new JsonResponse(["logs" => $logs]); + } + + protected function extractCSVData($tempFileIRI, $includeHeaders = true) + { + $tempUploadedFile = $this->get("api.iri_converter")->getItemFromIri($tempFileIRI); + $fileContents = $this->get('partkeepr_uploadedfile_service')->getStorage($tempUploadedFile)->read($tempUploadedFile->getFullFilename()); + + $tempFile = tempnam(sys_get_temp_dir(), "import"); + + file_put_contents($tempFile, $fileContents); + + $fp = fopen($tempFile, "r"); + + $data = []; + + if (!$includeHeaders) { + fgetcsv($fp); + } + + while (($row = fgetcsv($fp)) !== false) { + $data[] = $row; + } + + unlink($tempFile); + + return $data; + } +} diff --git a/src/PartKeepr/ImportBundle/DependencyInjection/Configuration.php b/src/PartKeepr/ImportBundle/DependencyInjection/Configuration.php @@ -0,0 +1,29 @@ +<?php + +namespace PartKeepr\ImportBundle\DependencyInjection; + +use Symfony\Component\Config\Definition\Builder\TreeBuilder; +use Symfony\Component\Config\Definition\ConfigurationInterface; + +/** + * This is the class that validates and merges configuration from your app/config files. + * + * To learn more see {@link http://symfony.com/doc/current/cookbook/bundles/extension.html#cookbook-bundles-extension-config-class} + */ +class Configuration implements ConfigurationInterface +{ + /** + * {@inheritdoc} + */ + public function getConfigTreeBuilder() + { + $treeBuilder = new TreeBuilder(); + $treeBuilder->root('partkeepr_import'); + + // Here you should define the parameters that are allowed to + // configure your bundle. See the documentation linked above for + // more information on that topic. + + return $treeBuilder; + } +} diff --git a/src/PartKeepr/ImportBundle/DependencyInjection/PartKeeprImportExtension.php b/src/PartKeepr/ImportBundle/DependencyInjection/PartKeeprImportExtension.php @@ -0,0 +1,28 @@ +<?php + +namespace PartKeepr\ImportBundle\DependencyInjection; + +use Symfony\Component\Config\FileLocator; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Loader; +use Symfony\Component\HttpKernel\DependencyInjection\Extension; + +/** + * This is the class that loads and manages your bundle configuration. + * + * To learn more see {@link http://symfony.com/doc/current/cookbook/bundles/extension.html} + */ +class PartKeeprImportExtension extends Extension +{ + /** + * {@inheritdoc} + */ + public function load(array $configs, ContainerBuilder $container) + { + $configuration = new Configuration(); + $this->processConfiguration($configuration, $configs); + + $loader = new Loader\XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); + $loader->load('services.xml'); + } +} diff --git a/src/PartKeepr/ImportBundle/PartKeeprImportBundle.php b/src/PartKeepr/ImportBundle/PartKeeprImportBundle.php @@ -0,0 +1,9 @@ +<?php + +namespace PartKeepr\ImportBundle; + +use Symfony\Component\HttpKernel\Bundle\Bundle; + +class PartKeeprImportBundle extends Bundle +{ +} diff --git a/src/PartKeepr/ImportBundle/Resources/config/routing.yml b/src/PartKeepr/ImportBundle/Resources/config/routing.yml @@ -0,0 +1,3 @@ +_importtest: + resource: "@PartKeeprImportBundle/Controller/ImportController.php" + type: annotation diff --git a/src/PartKeepr/ImportBundle/Resources/config/services.xml b/src/PartKeepr/ImportBundle/Resources/config/services.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" ?> + +<container xmlns="http://symfony.com/schema/dic/services" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd"> + + <services> + <service id="importer_service" class="PartKeepr\ImportBundle\Service\ImporterService"> + <argument type="service" id="doctrine"/> + <argument type="service" id="doctrine_reflection_service"/> + <argument type="service" id="doctrine_reflection_service.search_filter"/> + <argument type="service" id="api.iri_converter"/> + </service> + + </services> +</container> diff --git a/src/PartKeepr/ImportBundle/Service/ImporterService.php b/src/PartKeepr/ImportBundle/Service/ImporterService.php @@ -0,0 +1,121 @@ +<?php +namespace PartKeepr\ImportBundle\Service; + + +use Doctrine\Bundle\DoctrineBundle\Registry; +use Doctrine\ORM\EntityManager; +use Doctrine\ORM\UnitOfWork; +use Dunglas\ApiBundle\Api\IriConverter; +use PartKeepr\DoctrineReflectionBundle\Filter\AdvancedSearchFilter; +use PartKeepr\DoctrineReflectionBundle\Services\ReflectionService; +use PartKeepr\ImportBundle\Configuration\Configuration; +use Symfony\Component\PropertyAccess\PropertyAccessor; + +class ImporterService +{ + /** @var EntityManager */ + protected $em; + + protected $reflectionService; + + protected $importData; + + protected $baseEntity; + + protected $importConfiguration; + + protected $advancedSearchFilter; + + protected $iriConverter; + + public function __construct( + Registry $doctrine, + ReflectionService $reflectionService, + AdvancedSearchFilter $advancedSearchFilter, + IriConverter $iriConverter + ) { + $this->em = $doctrine->getManager(); + $this->reflectionService = $reflectionService; + $this->advancedSearchFilter = $advancedSearchFilter; + $this->iriConverter = $iriConverter; + } + + /** + * @param mixed $baseEntity + */ + public function setBaseEntity($baseEntity) + { + $this->baseEntity = $this->reflectionService->convertExtJSToPHPClassName($baseEntity); + } + + /** + * @param mixed $importConfiguration + */ + public function setImportConfiguration($importConfiguration) + { + $this->importConfiguration = $importConfiguration; + } + + public function setImportData($importData) + { + $this->importData = $importData; + } + + public function import() + { + $entities = []; + $logs = []; + + foreach ($this->importData as $row) { + list($entity, $log) = $this->parseConfiguration()->import($row); + $entities[] = $entity; + $logs[] = implode("<br/>", + [ "data" => implode(",",$row), '<p style="text-indent: 50px;">', "log" => " ".implode("<br/> ", $log), '</p>']); + } + + return [$entities, implode("<br/>", $logs)]; + } + + public function parseConfiguration() + { + $cm = $this->em->getClassMetadata($this->baseEntity); + + $configuration = new Configuration($cm, $this->baseEntity, $this->reflectionService, $this->em, + $this->advancedSearchFilter, $this->iriConverter); + $configuration->parseConfiguration($this->importConfiguration); + + return $configuration; + } + + public function describe($entity) + { + $accessor = new PropertyAccessor(); + + $description = []; + + switch ($this->em->getUnitOfWork()->getEntityState($entity)) { + case UnitOfWork::STATE_NEW: + $description["title"] = "Would create a new entity of type ".get_class($entity); + + $cm = $this->em->getClassMetadata(get_class($entity)); + foreach ($cm->getFieldNames() as $fieldName) { + $description["fields"][$fieldName] = $accessor->getValue($entity, $fieldName); + } + + foreach ($cm->getAssociationNames() as $associationMapping) { + $foo = $accessor->getValue($entity, $associationMapping); + + if ($foo !== null) { + $description["associations"][$associationMapping] = $foo->getId(); + } else { + $description["error"] = "Would stop import because a mapping was not found"; + } + } + break; + } + + $descriptions[] = $description; + + return $description; + } +} diff --git a/src/PartKeepr/PartBundle/DataFixtures/PartDataLoader.php b/src/PartKeepr/PartBundle/DataFixtures/PartDataLoader.php @@ -16,6 +16,8 @@ class PartDataLoader extends AbstractFixture $partUnit->setShortName('pcs'); $partUnit->setDefault(true); + $this->addReference("partunit.default", $partUnit); + $part = new Part(); $part->setName('FOOBAR'); $part->setPartUnit($partUnit); diff --git a/src/PartKeepr/PartBundle/Entity/Part.php b/src/PartKeepr/PartBundle/Entity/Part.php @@ -16,6 +16,7 @@ use PartKeepr\StockBundle\Entity\StockEntry; use PartKeepr\StorageLocationBundle\Entity\StorageLocation; use PartKeepr\UploadedFileBundle\Annotation\UploadedFileCollection; use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Validator\Constraints as Assert; /** * Represents a part in the database. The heart of our project. Handle with care! @@ -30,6 +31,7 @@ class Part extends BaseEntity * The category of the part. * * @ORM\ManyToOne(targetEntity="PartKeepr\PartBundle\Entity\PartCategory") + * @Assert\NotNull() * @Groups({"default"}) * * @var PartCategory @@ -41,6 +43,7 @@ class Part extends BaseEntity * * @ORM\Column * @Groups({"default"}) + * @Assert\NotBlank() * * @var string */ @@ -71,6 +74,7 @@ class Part extends BaseEntity * in "pieces", "meters" or "grams". * * @ORM\ManyToOne(targetEntity="PartKeepr\PartBundle\Entity\PartMeasurementUnit", inversedBy="parts") + * @Assert\NotNull() * @Groups({"default"}) * * @var PartMeasurementUnit @@ -81,6 +85,7 @@ class Part extends BaseEntity * Defines the storage location of this part. * * @ORM\ManyToOne(targetEntity="PartKeepr\StorageLocationBundle\Entity\StorageLocation") + * @Assert\NotNull() * @Groups({"default"}) * * @var StorageLocation @@ -132,7 +137,7 @@ class Part extends BaseEntity * * @todo It would be nice if we could get rid of that. * @ORM\Column(type="integer") - * @Groups({"default"}) + * @Groups({"readonly"}) * * @var int */ @@ -153,7 +158,7 @@ class Part extends BaseEntity * The average price for the part. Note that this is a cached value. * * @ORM\Column(type="decimal",precision=13,scale=4,nullable=false) - * @Groups({"default"}) + * @Groups({"readonly"}) * * @var float */ @@ -213,7 +218,7 @@ class Part extends BaseEntity * The create date+time for this part. * * @ORM\Column(type="datetime",nullable=true) - * @Groups({"default"}) + * @Groups({"readonly"}) * * @var \DateTime */ @@ -238,6 +243,7 @@ class Part extends BaseEntity /** * @ORM\Column(type="boolean",nullable=false) + * @Groups({"readonly"}) * * @var bool */ @@ -245,6 +251,7 @@ class Part extends BaseEntity /** * @ORM\Column(type="boolean",nullable=false) + * @Groups({"readonly"}) * * @var bool */ diff --git a/src/PartKeepr/PartBundle/Tests/InternalPartNumberTest.php b/src/PartKeepr/PartBundle/Tests/InternalPartNumberTest.php @@ -40,6 +40,7 @@ class InternalPartNumberTest extends WebTestCase "name" => "foobar", "storageLocation" => $iriConverter->getIriFromItem($this->fixtures->getReference("storagelocation.first")), "category" => $iriConverter->getIriFromItem($this->fixtures->getReference("partcategory.first")), + "partUnit" => $iriConverter->getIriFromItem($this->fixtures->getReference("partunit.default")), "internalPartNumber" => "foo123", ];