diff --git a/app/components/searchable_dropdown_component.rb b/app/components/searchable_dropdown_component.rb new file mode 100644 index 0000000000..9a53155085 --- /dev/null +++ b/app/components/searchable_dropdown_component.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class SearchableDropdownComponent < ViewComponent::Base + REMOVED_SEARCH_PLUGIN = { 'tom-select-options-value': '{ "plugins": [] }' }.freeze + MINIMUM_OPTIONS_FOR_SEARCH_FIELD = 11 # at least 11 options are required for the search field + + def initialize( + form:, + name:, + options:, + selected_option:, + placeholder_value:, + include_blank: false, + aria_label: '' + ) + @f = form + @name = name + @options = options + @selected_option = selected_option + @placeholder_value = placeholder_value + @include_blank = include_blank + @aria_label = aria_label + end + + private + + attr_reader :f, :name, :options, :selected_option, :placeholder_value, :include_blank, :aria_label + + def classes + "fullwidth #{remove_search_plugin? ? 'no-input' : ''}" + end + + def data + { + controller: "tom-select", + 'tom-select-placeholder-value': placeholder_value + }.merge(remove_search_plugin? ? REMOVED_SEARCH_PLUGIN : {}) + end + + def remove_search_plugin? + @remove_search_plugin ||= options.count < MINIMUM_OPTIONS_FOR_SEARCH_FIELD + end +end diff --git a/app/components/searchable_dropdown_component/searchable_dropdown_component.html.haml b/app/components/searchable_dropdown_component/searchable_dropdown_component.html.haml new file mode 100644 index 0000000000..10043eaa29 --- /dev/null +++ b/app/components/searchable_dropdown_component/searchable_dropdown_component.html.haml @@ -0,0 +1 @@ += f.select name, options_for_select(options, selected_option), { include_blank: }, class: classes, data:, 'aria-label': aria_label diff --git a/app/controllers/admin/products_v3_controller.rb b/app/controllers/admin/products_v3_controller.rb index 667706aeba..92386e1231 100644 --- a/app/controllers/admin/products_v3_controller.rb +++ b/app/controllers/admin/products_v3_controller.rb @@ -7,7 +7,7 @@ module Admin def index fetch_products - render "index", locals: { producers:, categories:, flash: } + render "index", locals: { producers:, categories:, tax_category_options:, flash: } end def bulk_update @@ -24,7 +24,8 @@ module Admin elsif product_set.errors.present? @error_counts = { saved: product_set.saved_count, invalid: product_set.invalid.count } - render "index", status: :unprocessable_entity, locals: { producers:, categories:, flash: } + render "index", status: :unprocessable_entity, + locals: { producers:, categories:, tax_category_options:, flash: } end end @@ -59,6 +60,10 @@ module Admin Spree::Taxon.order(:name).map { |c| [c.name, c.id] } end + def tax_category_options + Spree::TaxCategory.order(:name).pluck(:name, :id) + end + def fetch_products product_query = OpenFoodNetwork::Permissions.new(spree_current_user) .editable_products.merge(product_scope).ransack(ransack_query).result diff --git a/app/reflexes/products_reflex.rb b/app/reflexes/products_reflex.rb index 9cad290331..74a0b49873 100644 --- a/app/reflexes/products_reflex.rb +++ b/app/reflexes/products_reflex.rb @@ -89,8 +89,8 @@ class ProductsReflex < ApplicationReflex html: render(partial: "admin/products_v3/content", locals: { products: @products, pagy: @pagy, search_term: @search_term, producer_options: producers, producer_id: @producer_id, - category_options: categories, category_id: @category_id, - flashes: flash }) + category_options: categories, tax_category_options:, + category_id: @category_id, flashes: flash }) ) cable_ready.replace_state( @@ -125,6 +125,10 @@ class ProductsReflex < ApplicationReflex Spree::Taxon.order(:name).map { |c| [c.name, c.id] } end + def tax_category_options + Spree::TaxCategory.order(:name).pluck(:name, :id) + end + def fetch_products product_query = OpenFoodNetwork::Permissions.new(current_user) .editable_products.merge(product_scope).ransack(ransack_query).result(distinct: true) diff --git a/app/views/admin/products_v3/_content.html.haml b/app/views/admin/products_v3/_content.html.haml index dcb29e945f..0a9459abae 100644 --- a/app/views/admin/products_v3/_content.html.haml +++ b/app/views/admin/products_v3/_content.html.haml @@ -15,7 +15,7 @@ .container.results .sixteen.columns = render partial: 'sort', locals: { pagy: pagy, search_term: search_term, producer_id: producer_id, category_id: category_id } - = render partial: 'table', locals: { products: products } + = render partial: 'table', locals: { products:, producer_options:, category_options:, tax_category_options: } - if pagy.present? && pagy.pages > 1 = render partial: 'admin/shared/stimulus_pagination', locals: { pagy: pagy } - else diff --git a/app/views/admin/products_v3/_product_row.html.haml b/app/views/admin/products_v3/_product_row.html.haml index 5e11301a5e..f82ef26933 100644 --- a/app/views/admin/products_v3/_product_row.html.haml +++ b/app/views/admin/products_v3/_product_row.html.haml @@ -9,7 +9,7 @@ %td.field.naked_inputs = f.text_field :sku, 'aria-label': t('admin.products_page.columns.sku') = error_message_on product, :sku -%td.multi-field.naked_inputs{ 'data-controller': 'toggle-control', 'data-toggle-control-match-value': 'items' } +%td.field.naked_inputs{ 'data-controller': 'toggle-control', 'data-toggle-control-match-value': 'items' } = f.hidden_field :variant_unit = f.hidden_field :variant_unit_scale = f.select :variant_unit_with_scale, @@ -27,8 +27,13 @@ -# empty %td.align-right -# empty -%td.align-left - .content= product.supplier&.name +%td.naked_inputs + = render(SearchableDropdownComponent.new(form: f, + name: :supplier_id, + aria_label: t('.producer_field_name'), + options: producer_options, + selected_option: product.supplier_id, + placeholder_value: t('admin.products_v3.filters.search_for_producers'))) %td.align-left -# empty %td.align-left diff --git a/app/views/admin/products_v3/_table.html.haml b/app/views/admin/products_v3/_table.html.haml index 78d32be8b2..10b62f911e 100644 --- a/app/views/admin/products_v3/_table.html.haml +++ b/app/views/admin/products_v3/_table.html.haml @@ -66,17 +66,17 @@ controller: "nested-form product", action: 'rails-nested-form:add->bulk-form#registerElements' } } %tr - = render partial: 'product_row', locals: { product:, f: product_form } + = render partial: 'product_row', locals: { f: product_form, product:, producer_options: } - product.variants.each_with_index do |variant, variant_index| = form.fields_for("products][#{product_index}][variants_attributes][", variant, index: variant_index) do |variant_form| %tr.condensed{ 'data-controller': "variant" } - = render partial: 'variant_row', locals: { variant:, f: variant_form } + = render partial: 'variant_row', locals: { variant:, f: variant_form, category_options:, tax_category_options: } = form.fields_for("products][#{product_index}][variants_attributes][NEW_RECORD", product.variants.build) do |new_variant_form| %template{ 'data-nested-form-target': "template" } %tr.condensed{ 'data-controller': "variant" } - = render partial: 'variant_row', locals: { variant: new_variant_form.object, f: new_variant_form } + = render partial: 'variant_row', locals: { variant: new_variant_form.object, f: new_variant_form, category_options:, tax_category_options: } %tr{ 'data-nested-form-target': "target" } %tr.condensed diff --git a/app/views/admin/products_v3/_variant_row.html.haml b/app/views/admin/products_v3/_variant_row.html.haml index 97646ec1ba..53ea27aad5 100644 --- a/app/views/admin/products_v3/_variant_row.html.haml +++ b/app/views/admin/products_v3/_variant_row.html.haml @@ -40,11 +40,22 @@ = f.check_box :on_demand, 'data-action': 'change->toggle-control#disableIfPresent change->popout#closeIfChecked' = t(:on_demand) %td.align-left - .content= variant.product.supplier&.name # same as product -%td.align-left - .content= variant.primary_taxon&.name -%td.align-left - .content= (variant.tax_category_id ? variant.tax_category&.name : t('.none_tax_category')) # TODO: convert to dropdown + -# empty producer name +%td.field.naked_inputs + = render(SearchableDropdownComponent.new(form: f, + name: :primary_taxon_id, + options: category_options, + selected_option: variant.primary_taxon_id, + aria_label: t('.category_field_name'), + placeholder_value: t('admin.products_v3.filters.search_for_categories'))) +%td.field.naked_inputs + = render(SearchableDropdownComponent.new(form: f, + name: :tax_category_id, + options: tax_category_options, + selected_option: variant.tax_category_id, + include_blank: t('.none_tax_category'), + aria_label: t('.tax_category_field_name'), + placeholder_value: t('.search_for_tax_categories'))) %td.align-left -# empty %td.align-right diff --git a/app/views/admin/products_v3/index.html.haml b/app/views/admin/products_v3/index.html.haml index 4200cd0176..5087feddc2 100644 --- a/app/views/admin/products_v3/index.html.haml +++ b/app/views/admin/products_v3/index.html.haml @@ -15,7 +15,7 @@ = render partial: "content", locals: { products: @products, pagy: @pagy, search_term: @search_term, producer_options: producers, producer_id: @producer_id, category_options: categories, category_id: @category_id, - flashes: flash } + tax_category_options:, flashes: flash } - %w[product variant].each do |object_type| = render partial: 'delete_modal', locals: { object_type: } #modal-component diff --git a/app/webpacker/controllers/bulk_form_controller.js b/app/webpacker/controllers/bulk_form_controller.js index 602f7c0127..54a8a812df 100644 --- a/app/webpacker/controllers/bulk_form_controller.js +++ b/app/webpacker/controllers/bulk_form_controller.js @@ -135,10 +135,17 @@ export default class BulkFormController extends Controller { if (element.type == "checkbox") { return element.defaultChecked !== undefined && element.checked != element.defaultChecked; } else if (element.type == "select-one") { + // (weird) Behavior of select element's include_blank option in Rails: + // If a select field has include_blank option selected (its value will be ''), + // its respective option doesn't have the selected attribute + // but selectedOptions have that option present const defaultSelected = Array.from(element.options).find((opt) => opt.hasAttribute("selected"), ); - return element.selectedOptions[0] != defaultSelected; + const selectedOption = element.selectedOptions[0]; + const areBothBlank = selectedOption.value === '' && defaultSelected === undefined + + return !areBothBlank && selectedOption !== defaultSelected; } else { return element.defaultValue !== undefined && element.value != element.defaultValue; } diff --git a/app/webpacker/css/admin/products_v3.scss b/app/webpacker/css/admin/products_v3.scss index 8cb8ccbce2..a06019bebb 100644 --- a/app/webpacker/css/admin/products_v3.scss +++ b/app/webpacker/css/admin/products_v3.scss @@ -170,11 +170,10 @@ .field { padding: 0; } - .multi-field { - // Allow wrap with small gap - display: flex; - flex-wrap: wrap; - gap: 3px; + + .fullwidth + .field { + // Assume wrap, so add small gap + margin-top: 3px; } .ts-control { diff --git a/config/locales/en.yml b/config/locales/en.yml index 1fe516fee8..46197090c0 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -910,6 +910,11 @@ en: error: Unable to delete the variant variant_row: none_tax_category: None + search_for_tax_categories: "Search for tax categories" + category_field_name: "Category" + tax_category_field_name: "Tax Category" + product_row: + producer_field_name: "Producer" product_import: title: Product Import file_not_found: File not found or could not be opened diff --git a/spec/support/request/tomselect_helper.rb b/spec/support/request/tomselect_helper.rb new file mode 100644 index 0000000000..7cce8cbe9e --- /dev/null +++ b/spec/support/request/tomselect_helper.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module TomselectHelper + def tomselect_open(field_name) + page.find("##{field_name}-ts-control").click + end + + def tomselect_multiselect(value, options) + tomselect_wrapper = page.find_field(options[:from]).sibling(".ts-wrapper") + tomselect_wrapper.find(".ts-control").click + tomselect_wrapper.find(:css, '.ts-dropdown.multi .ts-dropdown-content .option', + text: value).click + end + + def tomselect_search_and_select(value, options) + tomselect_wrapper = page.find_field(options[:from]).sibling(".ts-wrapper") + tomselect_wrapper.find(".ts-control").click + # Use send_keys as setting the value directly doesn't trigger the search + tomselect_wrapper.find(:css, '.ts-dropdown input.dropdown-input').send_keys(value) + tomselect_wrapper.find(:css, '.ts-dropdown .ts-dropdown-content .option', text: value).click + end + + def tomselect_select(value, options) + tomselect_wrapper = page.find_field(options[:from]).sibling(".ts-wrapper") + tomselect_wrapper.find(".ts-control").click + + tomselect_wrapper.find(:css, '.ts-dropdown .ts-dropdown-content .option', text: value).click + end + + def open_tomselect_to_validate!(page, field_name) + tomselect_wrapper = page.find_field(field_name).sibling(".ts-wrapper") + tomselect_wrapper.find(".ts-control").click # open the dropdown + + raise 'Please pass the block for expectations' unless block_given? + + # execute block containing expectations + yield + + tomselect_wrapper.find( + '.ts-dropdown .ts-dropdown-content .option.active', + ).click # close the dropdown by selecting the already selected value + end +end diff --git a/spec/support/request/web_helper.rb b/spec/support/request/web_helper.rb index 42d7cb65b6..a5c4924a83 100644 --- a/spec/support/request/web_helper.rb +++ b/spec/support/request/web_helper.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true module WebHelper + include TomselectHelper + def have_input(name, opts = {}) selector = "[name='#{name}']" selector += "[placeholder='#{opts[:placeholder]}']" if opts.key? :placeholder @@ -86,32 +88,6 @@ module WebHelper find(:css, ".select2-result-label", text: options[:select_text] || value).click end - def tomselect_open(field_name) - page.find("##{field_name}-ts-control").click - end - - def tomselect_multiselect(value, options) - tomselect_wrapper = page.find_field(options[:from]).sibling(".ts-wrapper") - tomselect_wrapper.find(".ts-control").click - tomselect_wrapper.find(:css, '.ts-dropdown.multi .ts-dropdown-content .option', - text: value).click - end - - def tomselect_search_and_select(value, options) - tomselect_wrapper = page.find_field(options[:from]).sibling(".ts-wrapper") - tomselect_wrapper.find(".ts-control").click - # Use send_keys as setting the value directly doesn't trigger the search - tomselect_wrapper.find(:css, '.ts-dropdown input.dropdown-input').send_keys(value) - tomselect_wrapper.find(:css, '.ts-dropdown .ts-dropdown-content .option', text: value).click - end - - def tomselect_select(value, options) - tomselect_wrapper = page.find_field(options[:from]).sibling(".ts-wrapper") - tomselect_wrapper.find(".ts-control").click - - tomselect_wrapper.find(:css, '.ts-dropdown .ts-dropdown-content .option', text: value).click - end - def request_monitor_finished(controller = nil) page.evaluate_script("#{angular_scope(controller)}.scope().RequestMonitor.loading == false") end diff --git a/spec/system/admin/products_v3/products_spec.rb b/spec/system/admin/products_v3/products_spec.rb index 5d69b524cc..0eff69cde8 100644 --- a/spec/system/admin/products_v3/products_spec.rb +++ b/spec/system/admin/products_v3/products_spec.rb @@ -14,6 +14,10 @@ describe 'As an enterprise user, I can manage my products', feature: :admin_styl login_as user end + let(:producer_search_selector) { 'input[placeholder="Search for producers"]' } + let(:categories_search_selector) { 'input[placeholder="Search for categories"]' } + let(:tax_categories_search_selector) { 'input[placeholder="Search for tax categories"]' } + it "can see the new product page" do visit admin_products_url expect(page).to have_content "Bulk Edit Products" @@ -666,6 +670,117 @@ describe 'As an enterprise user, I can manage my products', feature: :admin_styl end end + describe "Changing producers, category and tax category" do + let!(:variant_a1) { + product_a.variants.first.tap{ |v| + v.update! display_name: "Medium box", sku: "APL-01", price: 5.25, on_hand: 5, + on_demand: false + } + } + let!(:product_a) { + create(:simple_product, name: "Apples", sku: "APL-00", + variant_unit: "weight", variant_unit_scale: 1) # Grams + } + + context "when they are under 11" do + before do + create_list(:supplier_enterprise, 9, users: [user]) + create_list(:tax_category, 9) + create_list(:taxon, 2) + + visit admin_products_url + end + + it "should not display search input, change the producers, category and tax category" do + producer_to_select = random_producer(product_a) + category_to_select = random_category(variant_a1) + tax_category_to_select = random_tax_category + + within row_containing_name(product_a.name) do + validate_tomselect_without_search!( + page, "Producer", + producer_search_selector + ) + tomselect_select(producer_to_select, from: "Producer") + end + + within row_containing_name(variant_a1.display_name) do + validate_tomselect_without_search!( + page, "Category", + categories_search_selector + ) + tomselect_select(category_to_select, from: "Category") + + validate_tomselect_without_search!( + page, "Tax Category", + tax_categories_search_selector + ) + tomselect_select(tax_category_to_select, from: "Tax Category") + end + + click_button "Save changes" + + expect(page).to have_content "Changes saved" + product_a.reload + variant_a1.reload + + expect(product_a.supplier.name).to eq(producer_to_select) + expect(variant_a1.primary_taxon.name).to eq(category_to_select) + expect(variant_a1.tax_category.name).to eq(tax_category_to_select) + end + end + + context "when they are over 11" do + before do + create_list(:supplier_enterprise, 11, users: [user]) + create_list(:tax_category, 11) + create_list(:taxon, 11) + + visit admin_products_url + end + + it "should display search input, change the producer" do + producer_to_select = random_producer(product_a) + category_to_select = random_category(variant_a1) + tax_category_to_select = random_tax_category + + within row_containing_name(product_a.name) do + validate_tomselect_with_search!( + page, "Producer", + producer_search_selector + ) + tomselect_search_and_select(producer_to_select, from: "Producer") + end + + within row_containing_name(variant_a1.display_name) do + sleep(0.1) + validate_tomselect_with_search!( + page, "Category", + categories_search_selector + ) + tomselect_search_and_select(category_to_select, from: "Category") + + sleep(0.1) + validate_tomselect_with_search!( + page, "Tax Category", + tax_categories_search_selector + ) + tomselect_search_and_select(tax_category_to_select, from: "Tax Category") + end + + click_button "Save changes" + + expect(page).to have_content "Changes saved" + product_a.reload + variant_a1.reload + + expect(product_a.supplier.name).to eq(producer_to_select) + expect(variant_a1.primary_taxon.name).to eq(category_to_select) + expect(variant_a1.tax_category.name).to eq(tax_category_to_select) + end + end + end + describe "edit image" do shared_examples "updating image" do it "saves product image" do @@ -1030,6 +1145,35 @@ describe 'As an enterprise user, I can manage my products', feature: :admin_styl end def tax_category_column - @tax_category_column ||= 'td:nth-child(10)' + @tax_category_column ||= '[data-controller="variant"] > td:nth-child(10)' + end + + def validate_tomselect_without_search!(page, field_name, search_selector) + open_tomselect_to_validate!(page, field_name) do + expect(page).not_to have_selector(search_selector) + end + end + + def validate_tomselect_with_search!(page, field_name, search_selector) + open_tomselect_to_validate!(page, field_name) do + expect(page).to have_selector(search_selector) + end + end + + def random_producer(product) + Enterprise.is_primary_producer + .where.not(id: product.supplier.id) + .pluck(:name).sample + end + + def random_category(variant) + Spree::Taxon + .where.not(id: variant.primary_taxon.id) + .pluck(:name).sample + end + + def random_tax_category + Spree::TaxCategory + .pluck(:name).sample end end