From 5258cba2a28ffe75cda2ede38f71f02b794e668a Mon Sep 17 00:00:00 2001 From: Rob H Date: Fri, 7 Jun 2013 09:26:00 +0530 Subject: [PATCH] BPUR: change to live tracking of dirty properties (variant price update still failing) --- .../javascripts/admin/bulk_product_update.js | 124 +++++++++++++----- .../spree/admin/products/bulk_index.html.haml | 14 +- .../unit/bulk_product_update_spec.js | 59 +++++++-- 3 files changed, 148 insertions(+), 49 deletions(-) diff --git a/app/assets/javascripts/admin/bulk_product_update.js b/app/assets/javascripts/admin/bulk_product_update.js index 5b5acb30e7..89539f9cd2 100644 --- a/app/assets/javascripts/admin/bulk_product_update.js +++ b/app/assets/javascripts/admin/bulk_product_update.js @@ -36,29 +36,69 @@ productsApp.directive('ngDecimal', function () { } }); +productsApp.directive('ngTrackProduct', function(){ + return { + require: 'ngModel', + link: function(scope, element, attrs, ngModel) { + var property = attrs.ngTrackProduct; + var clean_value = angular.copy(scope.product[property]); + element.bind('blur', function() { + if (scope.product[property] == clean_value) removeCleanProperty(scope.dirtyProducts, scope.product.id, property); + else addDirtyProperty(scope.dirtyProducts, scope.product.id, property, scope.product[property]); + scope.$apply(scope.displayDirtyProducts()); + }); + } + } +}); + +productsApp.directive('ngTrackVariant', function(){ + return { + require: 'ngModel', + link: function(scope, element, attrs, ngModel) { + var property = attrs.ngTrackVariant; + var clean_value = angular.copy(scope.variant[property]); + element.bind('blur', function() { + var dirtyVariants = {}; + if (scope.dirtyProducts.hasOwnProperty(scope.product.id) && scope.dirtyProducts[scope.product.id].hasOwnProperty("variants")) dirtyVariants = scope.dirtyProducts[scope.product.id].variants; + if (scope.variant[property] == clean_value){ + removeCleanProperty(dirtyVariants, scope.variant.id, property); + if (dirtyVariants == {}) removeCleanProperty(scope.dirtyProducts, scope.product.id, "variants"); + } + else { + addDirtyProperty(dirtyVariants, scope.variant.id, property, scope.variant[property]); + addDirtyProperty(scope.dirtyProducts, scope.product.id, "variants", dirtyVariants); + } + scope.$apply(scope.displayDirtyProducts()); + }); + } + } +}); + productsApp.controller('AdminBulkProductsCtrl', function($scope, $timeout, $http, dataFetcher) { + $scope.dirtyProducts = {}; + + $scope.updateStatusMessage = { + text: "", + style: {} + } + $scope.refreshSuppliers = function(){ dataFetcher('/enterprises/suppliers.json').then(function(data){ $scope.suppliers = data; }); }; - + $scope.refreshProducts = function(){ dataFetcher('/admin/products/bulk_index.json').then(function(data){ $scope.products = angular.copy(data); $scope.cleanProducts = angular.copy(data); }); }; - + $scope.updateOnHand = function(product){ product.on_hand = onHand(product); } - $scope.updateStatusMessage = { - text: "", - style: {} - } - $scope.updateProducts = function(productsToSubmit){ $scope.displayUpdating(); $http({ @@ -68,6 +108,7 @@ productsApp.controller('AdminBulkProductsCtrl', function($scope, $timeout, $http }) .success(function(data){ if (angular.toJson($scope.products) == angular.toJson(data)){ + $scope.products = angular.copy(data); $scope.cleanProducts = angular.copy(data); $scope.displaySuccess(); } @@ -79,13 +120,12 @@ productsApp.controller('AdminBulkProductsCtrl', function($scope, $timeout, $http $scope.displayFailure("Server returned with error status: "+status); }); } - + $scope.prepareProductsForSubmit = function(){ - var productsToSubmit = getDirtyObjects($scope.products,$scope.cleanProducts); - productsToSubmit = filterSubmitProducts(productsToSubmit); + var productsToSubmit = filterSubmitProducts($scope.dirtyProducts); $scope.updateProducts(productsToSubmit); } - + $scope.setMessage = function(model,text,style,timeout){ model.text = text; model.style = style; @@ -93,18 +133,24 @@ productsApp.controller('AdminBulkProductsCtrl', function($scope, $timeout, $http $timeout(function() { $scope.setMessage(model,"",{},false); }, timeout, true); } } - + $scope.displayUpdating = function(){ $scope.setMessage($scope.updateStatusMessage,"Updating...",{ color: "orange" },false); } - + $scope.displaySuccess = function(){ $scope.setMessage($scope.updateStatusMessage,"Update complete",{ color: "green" },3000); } - + $scope.displayFailure = function(failMessage){ $scope.setMessage($scope.updateStatusMessage,"Updating failed. "+failMessage,{ color: "red" },10000); } + + $scope.displayDirtyProducts = function(){ + var changedProductCount = Object.keys($scope.dirtyProducts).length; + if (changedProductCount > 0) $scope.setMessage($scope.updateStatusMessage,"Changes to "+Object.keys($scope.dirtyProducts).length+" products remain unsaved.",{ color: "gray" },false); + else $scope.setMessage($scope.updateStatusMessage,"",{},false); + } }); productsApp.factory('dataFetcher', function($http,$q){ @@ -221,31 +267,33 @@ function getDirtyObjects(testObjects, cleanObjects){ function filterSubmitProducts(productsToFilter){ var filteredProducts= []; - if (productsToFilter instanceof Array){ - for (i in productsToFilter) { - if (productsToFilter[i].hasOwnProperty("id")){ + if (productsToFilter instanceof Object){ + var productKeys = Object.keys(productsToFilter); + for (i in productKeys) { + if (productsToFilter[productKeys[i]].hasOwnProperty("id")){ var filteredProduct = {}; var filteredVariants = []; - if (productsToFilter[i].hasOwnProperty("variants")){ - for (j in productsToFilter[i].variants){ - if (productsToFilter[i].variants[j].deleted_at == null && productsToFilter[i].variants[j].hasOwnProperty("id")){ + if (productsToFilter[productKeys[i]].hasOwnProperty("variants")){ + var variantKeys = Object.keys(productsToFilter[productKeys[i]].variants); + for (j in variantKeys){ + if (productsToFilter[productKeys[i]].variants[variantKeys[j]].deleted_at == null && productsToFilter[productKeys[i]].variants[variantKeys[j]].hasOwnProperty("id")){ filteredVariants[j] = {}; - filteredVariants[j].id = productsToFilter[i].variants[j].id; - if (productsToFilter[i].variants[j].hasOwnProperty("on_hand")) filteredVariants[j].on_hand = productsToFilter[i].variants[j].on_hand; - if (productsToFilter[i].variants[j].hasOwnProperty("price")) filteredVariants[j].price = productsToFilter[i].variants[j].price; + filteredVariants[j].id = productsToFilter[productKeys[i]].variants[variantKeys[j]].id; + if (productsToFilter[productKeys[i]].variants[variantKeys[j]].hasOwnProperty("on_hand")) filteredVariants[j].on_hand = productsToFilter[productKeys[i]].variants[variantKeys[j]].on_hand; + if (productsToFilter[productKeys[i]].variants[variantKeys[j]].hasOwnProperty("price")) filteredVariants[j].price = productsToFilter[productKeys[i]].variants[variantKeys[j]].price; } } } var hasUpdatableProperty = false; - filteredProduct.id = productsToFilter[i].id; - if (productsToFilter[i].hasOwnProperty("name")) { filteredProduct.name = productsToFilter[i].name; hasUpdatableProperty = true; } - if (productsToFilter[i].hasOwnProperty("supplier_id")) { filteredProduct.supplier_id = productsToFilter[i].supplier_id; hasUpdatableProperty = true; } - //if (productsToFilter[i].hasOwnProperty("master")) filteredProduct.master_attributes = productsToFilter[i].master - if (productsToFilter[i].hasOwnProperty("price")) { filteredProduct.price = productsToFilter[i].price; hasUpdatableProperty = true; } - if (productsToFilter[i].hasOwnProperty("on_hand") && filteredVariants.length == 0) { filteredProduct.on_hand = productsToFilter[i].on_hand; hasUpdatableProperty = true; } //only update if no variants present - if (productsToFilter[i].hasOwnProperty("available_on")) { filteredProduct.available_on = productsToFilter[i].available_on; hasUpdatableProperty = true; } + filteredProduct.id = productsToFilter[productKeys[i]].id; + if (productsToFilter[productKeys[i]].hasOwnProperty("name")) { filteredProduct.name = productsToFilter[productKeys[i]].name; hasUpdatableProperty = true; } + if (productsToFilter[productKeys[i]].hasOwnProperty("supplier_id")) { filteredProduct.supplier_id = productsToFilter[productKeys[i]].supplier_id; hasUpdatableProperty = true; } + //if (productsToFilter[productKeys[i]].hasOwnProperty("master")) filteredProduct.master_attributes = productsToFilter[productKeys[i]].master + if (productsToFilter[productKeys[i]].hasOwnProperty("price")) { filteredProduct.price = productsToFilter[productKeys[i]].price; hasUpdatableProperty = true; } + if (productsToFilter[productKeys[i]].hasOwnProperty("on_hand") && filteredVariants.length == 0) { filteredProduct.on_hand = productsToFilter[productKeys[i]].on_hand; hasUpdatableProperty = true; } //only update if no variants present + if (productsToFilter[productKeys[i]].hasOwnProperty("available_on")) { filteredProduct.available_on = productsToFilter[productKeys[i]].available_on; hasUpdatableProperty = true; } if (filteredVariants.length > 0) { filteredProduct.variants_attributes = filteredVariants; hasUpdatableProperty = true; } // Note that the name of the property changes to enable mass assignment of variants attributes with rails if (hasUpdatableProperty) filteredProducts.push(filteredProduct); @@ -255,6 +303,22 @@ function filterSubmitProducts(productsToFilter){ return filteredProducts; } +function addDirtyProperty(dirtyObjects, objectID, propertyName, propertyValue){ + if (dirtyObjects.hasOwnProperty(objectID)){ + dirtyObjects[objectID][propertyName] = propertyValue; + } + else { + dirtyObjects[objectID] = {}; + dirtyObjects[objectID]["id"] = objectID; + dirtyObjects[objectID][propertyName] = propertyValue; + } +} + +function removeCleanProperty(dirtyObjects, objectID, propertyName){ + if (dirtyObjects.hasOwnProperty(objectID) && dirtyObjects[objectID].hasOwnProperty(propertyName)) delete dirtyObjects[objectID][propertyName]; + if (dirtyObjects.hasOwnProperty(objectID) && Object.keys(dirtyObjects[objectID]).length <= 1) delete dirtyObjects[objectID]; +} + function isEmpty(object){ for (var i in object){ if (object.hasOwnProperty(i)){ diff --git a/app/views/spree/admin/products/bulk_index.html.haml b/app/views/spree/admin/products/bulk_index.html.haml index 871b9939df..4fbcf6217f 100644 --- a/app/views/spree/admin/products/bulk_index.html.haml +++ b/app/views/spree/admin/products/bulk_index.html.haml @@ -23,24 +23,24 @@ %tbody{ 'ng-repeat' => 'product in products | filter:query' } %tr %td - %input{ 'ng-model' => "product.name", :name => 'product_name', :type => 'text' } + %input{ 'ng-model' => "product.name", :name => 'product_name', 'ng-track-product' => 'name', :type => 'text' } %td - %select.select2{ :name => 'supplier_id', 'ng-model' => 'product.supplier_id', 'ng-options' => 's.id as s.name for s in suppliers' } + %select.select2{ 'ng-model' => 'product.supplier_id', :name => 'supplier_id', 'ng-track-product' => 'supplier_id', 'ng-options' => 's.id as s.name for s in suppliers' } %td - %input{ 'ng-model' => 'product.price', 'ng-decimal' => :true, :name => 'price', :type => 'text' } + %input{ 'ng-model' => 'product.price', 'ng-decimal' => :true, :name => 'price', 'ng-track-product' => 'price', :type => 'text' } %td %span{ 'ng-bind' => 'product.on_hand', :name => 'on_hand', 'ng-show' => 'product.variants.length > 0' } - %input.field{ 'ng-model' => 'product.on_hand', :name => 'on_hand', 'ng-show' => 'product.variants.length == 0', :type => 'number' } + %input.field{ 'ng-model' => 'product.on_hand', :name => 'on_hand', 'ng-track-product' => 'on_hand', 'ng-show' => 'product.variants.length == 0', :type => 'number' } %td - %input{ 'ng-model' => 'product.available_on', :name => 'available_on', :type => 'text' } + %input{ 'ng-model' => 'product.available_on', :name => 'available_on', 'ng-track-product' => 'available_on', :type => 'text' } %tr{ 'ng-repeat' => 'variant in product.variants' } %td {{ variant.options_text }} %td %td - %input{ 'ng-model' => 'variant.price', 'ng-decimal' => :true, :name => 'variant_price', :type => 'text' } + %input{ 'ng-model' => 'variant.price', 'ng-decimal' => :true, :name => 'variant_price', 'ng-track-variant' => 'price', :type => 'text' } %td - %input.field{ 'ng-model' => 'variant.on_hand', 'ng-change' => 'updateOnHand(product)', :name => 'variant_on_hand', :type => 'number' } + %input.field{ 'ng-model' => 'variant.on_hand', 'ng-change' => 'updateOnHand(product)', :name => 'variant_on_hand', 'ng-track-variant' => 'on_hand', :type => 'number' } %td %input{:type => 'button', :value => 'Update', 'ng-click' => 'prepareProductsForSubmit()'} %span{ id: "update-status-message", 'ng-style' => 'updateStatusMessage.style' } diff --git a/spec/javascripts/unit/bulk_product_update_spec.js b/spec/javascripts/unit/bulk_product_update_spec.js index d54ed9ab66..19ec0361a8 100644 --- a/spec/javascripts/unit/bulk_product_update_spec.js +++ b/spec/javascripts/unit/bulk_product_update_spec.js @@ -142,10 +142,11 @@ describe("Auxillary functions", function(){ }); describe("filtering products", function(){ - it("only accepts and returns an array", function(){ + it("accepts an object or an array and only returns an array", function(){ expect( filterSubmitProducts( [] ) ).toEqual([]); expect( filterSubmitProducts( {} ) ).toEqual([]); - expect( filterSubmitProducts( { thingone: { id: 1, name: "lala" } } ) ).toEqual([]); + expect( filterSubmitProducts( { 1: { id: 1, name: "lala" } } ) ).toEqual( [ { id: 1, name: "lala" } ] ); + expect( filterSubmitProducts( [ { id: 1, name: "lala" } ] ) ).toEqual( [ { id: 1, name: "lala" } ] ); expect( filterSubmitProducts( 1 ) ).toEqual([]); expect( filterSubmitProducts( "2" ) ).toEqual([]); expect( filterSubmitProducts( null ) ).toEqual([]); @@ -283,6 +284,46 @@ describe("Auxillary functions", function(){ }); }); +describe("Maintaining a live record of dirty products and properties", function(){ + describe("adding product properties to the dirtyProducts object", function(){ // Applies to both products and variants + it("adds the product and the property to the list if property is dirty", function(){ + var dirtyProducts = { }; + addDirtyProperty(dirtyProducts, 1, "name", "Product 1"); + + expect(dirtyProducts).toEqual( { 1: { id: 1, name: "Product 1" } } ); + }); + + it("adds the relevant property to a product that is already in the list but which does not yet possess it if the property is dirty", function(){ + var dirtyProducts = { 1: { id: 1, notaname: "something" } }; + addDirtyProperty(dirtyProducts, 1, "name", "Product 3"); + + expect(dirtyProducts).toEqual( { 1: { id: 1, notaname: "something", name: "Product 3" } } ); + }); + + it("changes the relevant property of a product that is already in the list if the property is dirty", function(){ + var dirtyProducts = { 1: { id: 1, name: "Product 1" } }; + addDirtyProperty(dirtyProducts, 1, "name", "Product 2"); + + expect(dirtyProducts).toEqual( { 1: { id: 1, name: "Product 2" } } ); + }); + }); + + describe("removing properties of products which are clean", function(){ + it("removes the relevant property from a product if the property is clean and the product has that property", function(){ + var dirtyProducts = { 1: { id: 1, someProperty: "something", name: "Product 1" } }; + removeCleanProperty(dirtyProducts, 1, "name", "Product 1"); + + expect(dirtyProducts).toEqual( { 1: { id: 1, someProperty: "something" } } ); + }); + + it("removes the product from dirtyProducts if the property is clean and by removing an existing property on an id is left", function(){ + var dirtyProducts = { 1: { id: 1, name: "Product 1" } }; + removeCleanProperty(dirtyProducts, 1, "name", "Product 1"); + + expect(dirtyProducts).toEqual( { } ); + }); + }); +}); describe("AdminBulkProductsCtrl", function(){ describe("loading data upon initialisation", function(){ @@ -376,24 +417,18 @@ describe("AdminBulkProductsCtrl", function(){ describe("preparing products for submit",function(){ beforeEach(function(){ ctrl('AdminBulkProductsCtrl', { $scope: scope } ); - spyOn(window, "getDirtyObjects").andReturn( [ { id: 1, value: 1 }, { id:2, value: 2 } ] ); - spyOn(window, "filterSubmitProducts").andReturn( [ { id: 1, value: 3 }, { id:2, value: 4 } ] ); + spyOn(window, "filterSubmitProducts").andReturn( [ { id: 1, value: 3 }, { id: 2, value: 4 } ] ); spyOn(scope, "updateProducts"); - scope.products = [1,2,3,4,5]; - scope.cleanProducts = [1,2,3,4,5]; + scope.dirtyProducts = { 1: { id: 1 }, 2: { id: 2 } }; scope.prepareProductsForSubmit(); }); - it("fetches dirty products required for submitting", function(){ - expect(getDirtyObjects).toHaveBeenCalledWith([1,2,3,4,5],[1,2,3,4,5]); - }); - it("filters returned dirty products", function(){ - expect(filterSubmitProducts).toHaveBeenCalledWith( [ { id: 1, value: 1 }, { id:2, value: 2 } ] ); + expect(filterSubmitProducts).toHaveBeenCalledWith( { 1: { id: 1 }, 2: { id: 2 } } ); }); it("sends dirty and filtered objects to submitProducts()", function(){ - expect(scope.updateProducts).toHaveBeenCalledWith( [ { id: 1, value: 3 }, { id:2, value: 4 } ] ); + expect(scope.updateProducts).toHaveBeenCalledWith( [ { id: 1, value: 3 }, { id: 2, value: 4 } ] ); }); });