diff --git a/app/assets/javascripts/admin/product_import/controllers/feedback_panels.js.coffee b/app/assets/javascripts/admin/product_import/controllers/dropdown_panels.js.coffee similarity index 59% rename from app/assets/javascripts/admin/product_import/controllers/feedback_panels.js.coffee rename to app/assets/javascripts/admin/product_import/controllers/dropdown_panels.js.coffee index 2c0a2efb59..1403611168 100644 --- a/app/assets/javascripts/admin/product_import/controllers/feedback_panels.js.coffee +++ b/app/assets/javascripts/admin/product_import/controllers/dropdown_panels.js.coffee @@ -1,4 +1,4 @@ -angular.module("ofn.admin").controller "FeedbackPanelsCtrl", ($scope) -> +angular.module("ofn.admin").controller "DropdownPanelsCtrl", ($scope) -> $scope.active = false $scope.togglePanel = -> diff --git a/app/assets/stylesheets/admin/product_import.css.scss b/app/assets/stylesheets/admin/product_import.css.scss index 54164191b3..e99cc9beea 100644 --- a/app/assets/stylesheets/admin/product_import.css.scss +++ b/app/assets/stylesheets/admin/product_import.css.scss @@ -1,6 +1,6 @@ -div.feedback-section { +div.panel-section { - div.feedback-header { + div.panel-header { width: 100%; //font-size: 1.5em; clear: both; @@ -22,7 +22,7 @@ div.feedback-section { div.header-icon { width: 2.5em; text-align: center; - padding-top: 0.1em; + padding-top: 0.18em; .fa { font-size: 1.5em; @@ -48,17 +48,17 @@ div.feedback-section { } - div.feedback-header:hover { + div.panel-header:hover { cursor: pointer; background-color: #f7f7f7; } - div.feedback-header.active { + div.panel-header.active { background-color: #efefef; text-shadow: 1px 1px 0px rgba(255,255,255,0.75); } - div.feedback-panel { + div.panel-content { width: 100%; clear: both; //border: 1px solid #ccc; @@ -102,3 +102,61 @@ div.feedback-section { br.panels.clearfix { clear: both; } + +table.import-settings { + background-color: transparent !important; + width: auto; + + //select { + // width: 100%; + //} + tr { + + } + tbody tr:hover td { + background-color: #f3f3f3; + } + td { + border: 0; + border-bottom: 1px solid #eee; + text-align: left; + + input { + width: 15em; + } + input[type="checkbox"] { + width: auto; + } + + } + td.description { + font-weight: bold; + padding-right: 2.5em; + } + tr:first-child td { + //border-top: 1px solid #eee; + border-top: 0; + } + tr:last-child td { + //border-top: 1px solid #eee; + border-bottom: 0; + } + +} + +.panel-section.import-settings { + .header-icon { + color: #BFBFBF; + } + .header-description { + padding-left: 1em; + } +} + +.select2-search { + display: none; +} + +.select2-results { + margin: 0; +} diff --git a/app/controllers/admin/product_import_controller.rb b/app/controllers/admin/product_import_controller.rb index d0ddd069f1..819fd3823b 100644 --- a/app/controllers/admin/product_import_controller.rb +++ b/app/controllers/admin/product_import_controller.rb @@ -17,7 +17,7 @@ class Admin::ProductImportController < Spree::Admin::BaseController end def save - @importer = ProductImporter.new(File.new(params[:filepath]), editable_enterprises) + @importer = ProductImporter.new(File.new(params[:filepath]), editable_enterprises, params[:settings]) @importer.save_all if @importer.has_valid_entries? end diff --git a/app/models/product_importer.rb b/app/models/product_importer.rb index ee50e9e9c3..7bc5d0ff06 100644 --- a/app/models/product_importer.rb +++ b/app/models/product_importer.rb @@ -5,10 +5,9 @@ class ProductImporter include ActiveModel::Conversion include ActiveModel::Validations - def initialize(file, editable_enterprises, options={}) + def initialize(file, editable_enterprises, import_settings={}) if file.is_a?(File) @file = file - @options = options @sheet = open_spreadsheet @valid_entries = {} @invalid_entries = {} @@ -21,10 +20,11 @@ class ProductImporter @variants_created = 0 @variants_updated = 0 + @import_settings = import_settings @editable_enterprises = {} editable_enterprises.map { |e| @editable_enterprises[e.name] = e.id } - @non_display_attributes = 'id', 'product_id', 'variant_id', 'supplier_id', 'primary_taxon_id', 'category_id', 'shipping_category_id', 'tax_category_id' + @non_display_attributes = 'id', 'product_id', 'variant_id', 'supplier_id', 'primary_taxon_id', 'category_id', 'shipping_category_id', 'tax_category_id', 'on_hand_nil' validate_all if @sheet else @@ -144,12 +144,8 @@ class ProductImporter supplier_validation(line_number, entry) category_validation(line_number, entry) - - # Ensure on_hand isn't nil because Spree::Product and - # Spree::Variant each validate it differently - entry['on_hand'] = 0 if entry['on_hand'].nil? - set_update_status(line_number, entry) + mark_as_valid(line_number, entry) unless entry_invalid?(line_number) end @@ -264,14 +260,9 @@ class ProductImporter product = Spree::Product.new() product.assign_attributes(entry.except('id')) + assign_defaults(product, entry) if product.save - # Ensure display_name and on_demand are copied to new variant - if entry['display_name'] || entry['on_demand'] - variant = product.variants.first - variant.display_name = entry['display_name'] if entry['display_name'] - variant.on_demand = entry['on_demand'] if entry['on_demand'] - variant.save - end + ensure_variant_updated(entry, product) @products_created += 1 else self.errors.add("Line #{line_number}:", product.errors.full_messages) @@ -282,6 +273,7 @@ class ProductImporter @variants_to_update.each do |line_number, data| variant = data[:variant] + assign_defaults(variant, data[:entry]) if variant.valid? and variant.save @variants_updated += 1 else @@ -291,6 +283,7 @@ class ProductImporter @variants_to_create.each do |line_number, data| new_variant = data[:variant] + assign_defaults(new_variant, data[:entry]) if new_variant.valid? and new_variant.save @variants_created += 1 else @@ -303,6 +296,29 @@ class ProductImporter total_saved_count end + def assign_defaults(object, entry) + @import_settings[entry['supplier_id'].to_s]['defaults'].each do |attribute, setting| + case setting['mode'] + when 'overwrite_all' + object.assign_attributes(attribute => setting['value']) + when 'overwrite_empty' + if object.send(attribute).blank? or (attribute == 'on_hand' and entry['on_hand_nil']) + object.assign_attributes(attribute => setting['value']) + end + end + end + end + + def ensure_variant_updated(entry, product) + # Ensure display_name and on_demand are copied to new product's variant + if entry['display_name'] || entry['on_demand'] + variant = product.variants.first + variant.display_name = entry['display_name'] if entry['display_name'] + variant.on_demand = entry['on_demand'] if entry['on_demand'] + variant.save + end + end + def set_update_status(line_number, entry) # Find product with matching supplier and name match = Spree::Product.where(supplier_id: entry['supplier_id'], name: entry['name'], deleted_at: nil).first @@ -337,6 +353,7 @@ class ProductImporter def mark_as_existing_variant(line_number, entry, existing_variant) existing_variant.assign_attributes(entry.except('id', 'product_id')) + check_on_hand_nil(entry, existing_variant) if existing_variant.valid? @variants_to_update[line_number] = {entry: entry, variant: existing_variant} unless entry_invalid?(line_number) else @@ -347,6 +364,7 @@ class ProductImporter def mark_as_new_variant(line_number, entry, product_id) new_variant = Spree::Variant.new(entry.except('id', 'product_id')) new_variant.product_id = product_id + check_on_hand_nil(entry, new_variant) if new_variant.valid? @variants_to_create[line_number] = {entry: entry, variant: new_variant} unless entry_invalid?(line_number) else @@ -354,6 +372,13 @@ class ProductImporter end end + def check_on_hand_nil(entry, variant) + if entry['on_hand'].blank? + variant.on_hand = 0 + entry['on_hand_nil'] = true + end + end + def delete_uploaded_file # Only delete if file is in '/tmp/product_import' directory if @file.path == Rails.root.join('tmp', 'product_import').to_s diff --git a/app/views/admin/product_import/_import_options.html.haml b/app/views/admin/product_import/_import_options.html.haml index e69de29bb2..64f9b657ff 100644 --- a/app/views/admin/product_import/_import_options.html.haml +++ b/app/views/admin/product_import/_import_options.html.haml @@ -0,0 +1,21 @@ +%h5 Import options and defaults +%br + +- @importer.suppliers_index.each do |name, id| + - if name and id + %div.panel-section.import-settings{ng: {controller: 'DropdownPanelsCtrl'}} + %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active}'}} + %div.header-caret + %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}"}} + %div.header-icon + %i.fa.fa-edit + -#%div.header-count + -# %strong.invalid-count= @importer.invalid_count + %div.header-description + = name + %div.panel-content{ng: {hide: '!active'}} + = render 'options_form', id: id, name: name + + +%br.panels.clearfix +%br diff --git a/app/views/admin/product_import/_import_review.html.haml b/app/views/admin/product_import/_import_review.html.haml index 9422be87f0..52cc334ca2 100644 --- a/app/views/admin/product_import/_import_review.html.haml +++ b/app/views/admin/product_import/_import_review.html.haml @@ -1,8 +1,8 @@ %h5 Import validation overview %br -%div.feedback-section - %div.feedback-header +%div.panel-section + %div.panel-header %div.header-caret -#%i.icon-chevron-right{ng: {hide: 'active'}} -#%i.icon-chevron-down{ng: {hide: '!active'}} @@ -12,51 +12,48 @@ %strong.item-count= @importer.item_count %div.header-description Entries found in imported file - -#%div.feedback-panel{ng: {hide: '!active'}} + -#%div.panel-content{ng: {hide: '!active'}} -# Content goes here -%div.feedback-section{ng: {controller: 'FeedbackPanelsCtrl', init: "count = #{@importer.invalid_count}"}} - %div.feedback-header{ng: {click: 'togglePanel()', class: '{active: active && count}'}} +%div.panel-section{ng: {controller: 'DropdownPanelsCtrl', init: "count = #{@importer.invalid_count}"}} + %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && count}'}} %div.header-caret - %i.icon-chevron-right{ng: {hide: 'active || count == 0'}} - %i.icon-chevron-down{ng: {hide: '!active || count == 0'}} + %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'count == 0'}} %div.header-icon %i.fa.fa-warning %div.header-count %strong.invalid-count= @importer.invalid_count %div.header-description Items contain errors and will not be imported - %div.feedback-panel{ng: {hide: '!active || count == 0'}} + %div.panel-content{ng: {hide: '!active || count == 0'}} = render 'errors_list' %br = render 'entries_table', entries: @importer.invalid_entries -%div.feedback-section{ng: {controller: 'FeedbackPanelsCtrl', init: "count = #{@importer.products_create_count}"}} - %div.feedback-header{ng: {click: 'togglePanel()', class: '{active: active && count}'}} +%div.panel-section{ng: {controller: 'DropdownPanelsCtrl', init: "count = #{@importer.products_create_count}"}} + %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && count}'}} %div.header-caret - %i.icon-chevron-right{ng: {hide: 'active || count == 0'}} - %i.icon-chevron-down{ng: {hide: '!active || count == 0'}} + %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'count == 0'}} %div.header-icon %i.fa.fa-check-circle %div.header-count %strong.create-count= @importer.products_create_count %div.header-description Products will be created - %div.feedback-panel{ng: {hide: '!active || count == 0'}} + %div.panel-content{ng: {hide: '!active || count == 0'}} = render 'entries_table', entries: @importer.products_to_create -%div.feedback-section{ng: {controller: 'FeedbackPanelsCtrl', init: "count = #{@importer.products_update_count}"}} - %div.feedback-header{ng: {click: 'togglePanel()', class: '{active: active && count}'}} +%div.panel-section{ng: {controller: 'DropdownPanelsCtrl', init: "count = #{@importer.products_update_count}"}} + %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && count}'}} %div.header-caret - %i.icon-chevron-right{ng: {hide: 'active || count == 0'}} - %i.icon-chevron-down{ng: {hide: '!active || count == 0'}} + %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'count == 0'}} %div.header-icon %i.fa.fa-check-circle %div.header-count %strong.update-count= @importer.products_update_count %div.header-description Products will be updated - %div.feedback-panel{ng: {hide: '!active || count == 0'}} + %div.panel-content{ng: {hide: '!active || count == 0'}} = render 'entries_table', entries: @importer.products_to_update %br.panels.clearfix \ No newline at end of file diff --git a/app/views/admin/product_import/_options_form.html.haml b/app/views/admin/product_import/_options_form.html.haml new file mode 100644 index 0000000000..040a6d4f44 --- /dev/null +++ b/app/views/admin/product_import/_options_form.html.haml @@ -0,0 +1,35 @@ +%table.import-settings + %tr + %td.description + Remove absent products? + %td + = check_box_tag "settings[#{id}][products_absent]", 1, false + %td + %tr + %td.description + Set default stock level + %td + = select_tag "settings[#{id}][defaults][on_hand][mode]", options_for_select({"Don't overwrite" => :overwrite_none, "Overwrite all" => :overwrite_all, "Overwrite if empty" => :overwrite_empty}), {class: 'select2 fullwidth'} + %td + = number_field_tag "settings[#{id}][defaults][on_hand][value]", 0 + %tr + %td.description + Set default tax category + %td + = select_tag "settings[#{id}][defaults][tax_category_id][mode]", options_for_select({"Don't overwrite" => :overwrite_none, "Overwrite all" => :overwrite_all, "Overwrite if empty" => :overwrite_empty}), {class: 'select2 fullwidth'} + %td + = select_tag "settings[#{id}][defaults][tax_category_id][value]", options_for_select(@tax_categories.map {|tc| [tc.name, tc.id]}), {prompt: 'None', class: 'select2 fullwidth'} + %tr + %td.description + Set default shipping category + %td + = select_tag "settings[#{id}][defaults][shipping_category_id][mode]", options_for_select({"Don't overwrite" => :overwrite_none, "Overwrite all" => :overwrite_all, "Overwrite if empty" => :overwrite_empty}), {class: 'select2 fullwidth'} + %td + = select_tag "settings[#{id}][defaults][shipping_category_id][value]", options_for_select(@shipping_categories.map {|sc| [sc.name, sc.id]}), {prompt: 'None', class: 'select2 fullwidth'} + %tr + %td.description + Set default available date + %td + = select_tag "settings[#{id}][defaults][available_on][mode]", options_for_select({"Don't overwrite" => :overwrite_none, "Overwrite all" => :overwrite_all, "Overwrite if empty" => :overwrite_empty}), {class: 'select2 fullwidth'} + %td + = text_field_tag "settings[#{id}][defaults][available_on][value]", nil, {class: 'datepicker', placeholder: 'Today'} diff --git a/app/views/admin/product_import/import.html.haml b/app/views/admin/product_import/import.html.haml index de0a1ec20e..74422c162f 100644 --- a/app/views/admin/product_import/import.html.haml +++ b/app/views/admin/product_import/import.html.haml @@ -10,6 +10,8 @@ %p There are no entries that can be saved %br + = render 'import_options' if @importer.has_valid_entries? + = render 'import_review' - if @importer.has_valid_entries? diff --git a/app/views/admin/product_import/save.html.haml b/app/views/admin/product_import/save.html.haml index 2b4abdd911..65c7663113 100644 --- a/app/views/admin/product_import/save.html.haml +++ b/app/views/admin/product_import/save.html.haml @@ -15,8 +15,8 @@ %br -- if !@importer.errors.full_messages - %h5 All #{importer.total_saved_count} items saved successfully +- if @importer.errors.count == 0 + %h5 All #{@importer.total_saved_count} items saved successfully - else %h5 Errors - @importer.errors.full_messages.each do |error| diff --git a/spec/features/admin/product_import_spec.rb b/spec/features/admin/product_import_spec.rb index 61aa8a4d03..28976fee43 100644 --- a/spec/features/admin/product_import_spec.rb +++ b/spec/features/admin/product_import_spec.rb @@ -11,9 +11,11 @@ feature "Product Import", js: true do let!(:enterprise2) { create(:supplier_enterprise, owner: admin, name: "Another Enterprise") } let!(:category) { create(:taxon, name: 'Vegetables') } let!(:category2) { create(:taxon, name: 'Cake') } + let!(:tax_category) { create(:tax_category) } + let!(:tax_category2) { create(:tax_category) } + let!(:shipping_category) { create(:shipping_category) } let!(:product) { create(:simple_product, supplier: enterprise, name: 'Hypothetical Cake') } let!(:variant) { create(:variant, product_id: product.id, price: '8.50', count_on_hand: '100', unit_value: '500', display_name: 'Preexisting Banana') } - let(:permissions) { OpenFoodNetwork::Permissions.new(user) } describe "when importing products from uploaded file" do before { quick_login_as_admin } @@ -77,7 +79,7 @@ feature "Product Import", js: true do carrots.price.should == 3.20 end - it "displays info about invalid entries but no save button if all invalid" do + it "displays info about invalid entries but no save button if all items are invalid" do csv_data = CSV.generate do |csv| csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale"] csv << ["Bad Carrots", "Unkown Enterprise", "Mouldy vegetables", "666", "3.20", "", "weight", ""] @@ -130,6 +132,37 @@ feature "Product Import", js: true do updated_banana.price.should == 5.50 updated_banana.on_hand.should == 5 end + + it "can add a new product and sub-variants of that product at the same time" do + csv_data = CSV.generate do |csv| + csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale", "display_name"] + csv << ["Potatoes", "User Enterprise", "Vegetables", "5", "3.50", "500", "weight", "1000", "Small Bag"] + csv << ["Potatoes", "User Enterprise", "Vegetables", "6", "5.50", "2000", "weight", "1000", "Big Bag"] + end + File.write('/tmp/test.csv', csv_data) + + visit main_app.admin_product_import_path + attach_file 'file', '/tmp/test.csv' + click_button 'Import' + + expect(page).to have_selector '.item-count', text: "2" + expect(page).to have_selector '.invalid-count', text: "0" + expect(page).to have_selector '.create-count', text: "2" + expect(page).to have_selector '.update-count', text: "0" + + click_button 'Save' + expect(page).to have_content "Products created: 2" + + small_bag = Spree::Variant.find_by_display_name('Small Bag') + small_bag.product.name.should == 'Potatoes' + small_bag.price.should == 3.50 + small_bag.on_hand.should == 5 + + big_bag = Spree::Variant.find_by_display_name('Big Bag') + big_bag.product.name.should == 'Potatoes' + big_bag.price.should == 5.50 + big_bag.on_hand.should == 6 + end end describe "when dealing with uploaded files" do @@ -200,4 +233,57 @@ feature "Product Import", js: true do end end + describe "applying settings and defaults on import" do + before { quick_login_as_admin } + + it "overwrites fields with selected defaults" do + csv_data = CSV.generate do |csv| + csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale", "tax_category_id", "available_on"] + csv << ["Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "weight", "1", tax_category.id, ""] + csv << ["Potatoes", "User Enterprise", "Vegetables", "6", "6.50", "1000", "weight", "1000", "", ""] + end + File.write('/tmp/test.csv', csv_data) + + visit main_app.admin_product_import_path + + attach_file 'file', '/tmp/test.csv' + click_button 'Import' + + within 'div.import-settings' do + find('div.header-description').click # Import settings tab + expect(page).to have_selector "#settings_#{enterprise.id}_defaults_on_hand_mode", visible: false + + # Overwrite stock level of all items to 9000 + select 'Overwrite all', from: "settings_#{enterprise.id}_defaults_on_hand_mode", visible: false + fill_in "settings_#{enterprise.id}_defaults_on_hand_value", with: '9000' + + # Overwrite default tax category, but only where field is empty + select 'Overwrite if empty', from: "settings_#{enterprise.id}_defaults_tax_category_id_mode", visible: false + select tax_category2.name, from: "settings_#{enterprise.id}_defaults_tax_category_id_value", visible: false + + # Set default shipping category (field not present in file) + select 'Overwrite all', from: "settings_#{enterprise.id}_defaults_shipping_category_id_mode", visible: false + select shipping_category.name, from: "settings_#{enterprise.id}_defaults_shipping_category_id_value", visible: false + + # Set available_on date + select 'Overwrite all', from: "settings_#{enterprise.id}_defaults_available_on_mode", visible: false + find("input#settings_#{enterprise.id}_defaults_available_on_value").set '2020-01-01' + end + + click_button 'Save' + expect(page).to have_content "Products created: 2" + + carrots = Spree::Product.find_by_name('Carrots') + carrots.on_hand.should == 9000 + carrots.tax_category_id.should == tax_category.id + carrots.shipping_category_id.should == shipping_category.id + carrots.available_on.should be_within(1.day).of(Time.zone.local(2020, 1, 1)) + + potatoes = Spree::Product.find_by_name('Potatoes') + potatoes.on_hand.should == 9000 + potatoes.tax_category_id.should == tax_category2.id + potatoes.shipping_category_id.should == shipping_category.id + potatoes.available_on.should be_within(1.day).of(Time.zone.local(2020, 1, 1)) + end + end end \ No newline at end of file