diff --git a/admin/app/components/solidus_admin/base_component.rb b/admin/app/components/solidus_admin/base_component.rb index e39c1707a4b..de18ea8859e 100644 --- a/admin/app/components/solidus_admin/base_component.rb +++ b/admin/app/components/solidus_admin/base_component.rb @@ -9,7 +9,9 @@ class BaseComponent < ViewComponent::Base include SolidusAdmin::ComponentsHelper include SolidusAdmin::StimulusHelper include SolidusAdmin::VoidElementsHelper - include Turbo::FramesHelper + include SolidusAdmin::FlashHelper + include ::Turbo::FramesHelper + include ::Turbo::StreamsHelper def icon_tag(name, **attrs) render component("ui/icon").new(name:, **attrs) diff --git a/admin/app/components/solidus_admin/layout/flashes/toasts/component.html.erb b/admin/app/components/solidus_admin/layout/flashes/toasts/component.html.erb new file mode 100644 index 00000000000..14ea6c06657 --- /dev/null +++ b/admin/app/components/solidus_admin/layout/flashes/toasts/component.html.erb @@ -0,0 +1,5 @@ + diff --git a/admin/app/components/solidus_admin/layout/flashes/toasts/component.rb b/admin/app/components/solidus_admin/layout/flashes/toasts/component.rb new file mode 100644 index 00000000000..3aecd62d0a0 --- /dev/null +++ b/admin/app/components/solidus_admin/layout/flashes/toasts/component.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class SolidusAdmin::Layout::Flashes::Toasts::Component < SolidusAdmin::BaseComponent + attr_reader :toasts + + def initialize(toasts:) + @toasts = toasts + end +end diff --git a/admin/app/components/solidus_admin/products/show/categories/new/component.html.erb b/admin/app/components/solidus_admin/products/show/categories/new/component.html.erb new file mode 100644 index 00000000000..835105c8def --- /dev/null +++ b/admin/app/components/solidus_admin/products/show/categories/new/component.html.erb @@ -0,0 +1,17 @@ +<%= turbo_frame_tag :new_product_category, target: :product_organization_frame do %> + <%= render component("ui/modal").new(title: t(".title")) do |modal| %> + <%= form_for @taxon, url: solidus_admin.product_taxons_path(@product), method: :post, html: { id: dom_id(@taxon) } do |f| %> +
+ <%= render component("ui/forms/field").text_field(f, :name, class: "required") %> + <%= render component("ui/forms/field").select(f, :parent_id, parent_taxon_options, include_blank: t(".none")) %> + <%= render component("ui/forms/field").text_area(f, :description) %> +
+ <% end %> + <% modal.with_actions do %> +
+ <%= render component("ui/button").new(scheme: :secondary, text: t('.cancel')) %> +
+ <%= render component("ui/button").new(type: :submit, text: t('.submit'), form: dom_id(@taxon)) %> + <% end %> + <% end %> +<% end %> diff --git a/admin/app/components/solidus_admin/products/show/categories/new/component.rb b/admin/app/components/solidus_admin/products/show/categories/new/component.rb new file mode 100644 index 00000000000..a60bfd51937 --- /dev/null +++ b/admin/app/components/solidus_admin/products/show/categories/new/component.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class SolidusAdmin::Products::Show::Categories::New::Component < SolidusAdmin::BaseComponent + def initialize(product:, taxon: nil) + @product = product + @taxon = taxon || product.taxons.build + end + + private + + def parent_taxon_options + @parent_taxon_options ||= Spree::Taxon.order(:lft).pluck(:name, :id, :depth).map do + name, id, depth = _1 + ["#{'    ' * depth} → #{name}", id, { data: { item_label: name } }] + end + end +end diff --git a/admin/app/components/solidus_admin/products/show/categories/new/component.yml b/admin/app/components/solidus_admin/products/show/categories/new/component.yml new file mode 100644 index 00000000000..6b3ae093e25 --- /dev/null +++ b/admin/app/components/solidus_admin/products/show/categories/new/component.yml @@ -0,0 +1,5 @@ +en: + cancel: "Cancel" + none: "None" + submit: "Add Category" + title: "New Category" diff --git a/admin/app/components/solidus_admin/products/show/component.html.erb b/admin/app/components/solidus_admin/products/show/component.html.erb index f8095763f04..4b7d3b4b6f4 100644 --- a/admin/app/components/solidus_admin/products/show/component.html.erb +++ b/admin/app/components/solidus_admin/products/show/component.html.erb @@ -83,13 +83,48 @@ ) %> <% end %> - <%= render component("ui/panel").new(title: t(".options")) do %> - <%= render component("ui/forms/field").select( - f, - :option_type_ids, - option_type_options, - multiple: true, - "size" => option_type_options.size, + <%= render component("ui/panel").new(title: t(".options")) do |panel| %> + <% if @product.product_option_types.present? %> + <% panel.with_section do %> +
+ <% @product.product_option_types.includes(option_type: :option_values).order(:position).each do |product_option| %> +
> +
+
+ <%= render component("ui/icon").new(name: "draggable", class: "w-6 h-6 cursor-grab handle fill-gray-500") %> +
+
+ <%= product_option.name %>:<%= product_option.presentation %> +
+ <% product_option.option_values.each do |value| %> + <%= render component("ui/badge").new(name: "#{value.name}:#{value.presentation}") %> + <% end %> +
+
+
+
+ <%= render component("ui/button").new(tag: :a, href: spree.edit_admin_option_type_path(product_option.option_type), scheme: :secondary, text: t(".edit")) %> +
+
+ <% end %> +
+ <% end %> + <% end %> + +
+ <%= hidden_field_tag "#{f.object_name}[option_type_ids][]", nil %> + <%= render component("ui/forms/field").select( + f, + :option_type_ids, + option_type_options, + multiple: true + ) %> + <%= render component("ui/button").new(type: :submit, text: t(".save")) %> +
+ + <% panel.with_action( + name: t(".manage_options"), + href: solidus_admin.option_types_path ) %> <% end %> @@ -123,13 +158,20 @@ <% end %> <% end %> - <%= render component("ui/panel").new(title: t(".product_organization")) do %> - <%= render component("ui/forms/field").select( - f, - :taxon_ids, - taxon_options, - multiple: true, - "size" => taxon_options.size, # use a string key to avoid setting the size of the component + <%= render component("ui/panel").new(title: t(".product_organization")) do |panel| %> + <%= f.hidden_field :taxon_ids, multiple: true, value: nil %> + <%= render component("turbo/target_frame").new(:product_organization_frame, source: :new_product_category) do %> + <%= render component("ui/forms/field").select( + f, + :taxon_ids, + taxon_options, + multiple: true + ) %> + <% end %> + <% panel.with_action( + name: t(".add_new_category"), + href: solidus_admin.new_product_taxon_path(@product), + data: { turbo_frame: :new_product_category } ) %> <% end %> <% end %> @@ -151,3 +193,5 @@ <%= render component("ui/button").new(tag: :button, text: t(".save"), form: form_id) %> <% end %> <% end %> + +<%= turbo_frame_tag :new_product_category, target: :product_organization_frame %> diff --git a/admin/app/components/solidus_admin/products/show/component.rb b/admin/app/components/solidus_admin/products/show/component.rb index 66ae89250fd..f07d66b9cdd 100644 --- a/admin/app/components/solidus_admin/products/show/component.rb +++ b/admin/app/components/solidus_admin/products/show/component.rb @@ -16,13 +16,13 @@ def form_id def taxon_options @taxon_options ||= Spree::Taxon.order(:lft).pluck(:name, :id, :lft, :depth).map do name, id, _lft, depth = _1 - ["#{'    ' * depth} → #{name}", id] + ["#{'    ' * depth} → #{name}", id, { data: { item_label: name } }] end end def option_type_options @option_type_options ||= Spree::OptionType.order(:presentation).pluck(:presentation, :name, :id).map do - ["#{_1} (#{_2})", _3] + ["#{_2}:#{_1}", _3] end end diff --git a/admin/app/components/solidus_admin/products/show/component.yml b/admin/app/components/solidus_admin/products/show/component.yml index 58a100b8332..1c992f78635 100644 --- a/admin/app/components/solidus_admin/products/show/component.yml +++ b/admin/app/components/solidus_admin/products/show/component.yml @@ -1,11 +1,18 @@ en: - save: "Save" + add_new_category: "Add new category" back: "Back" - duplicate: "Duplicate" - view: "View online" delete: "Delete" delete_confirmation: "Are you sure you want to delete this product?" + duplicate: "Duplicate" + edit: "Edit" + hints: + available_on_html: "Product availability starts from the set date.
Empty date indicates no availability." + discontinue_on_html: "Product availability ends from the set date.
Empty date indicates continuous availability." + promotionable_html: "Promotions can apply to this product" + shipping_category_html: "Manage Shipping in Settings" + tax_category_html: "Manage Taxes in Settings" manage_images: "Manage images" + manage_options: "Manage option types" manage_properties: "Manage product specifications" manage_stock: "Manage stock" media: "Media" @@ -14,13 +21,9 @@ en: pricing: "Pricing" product_organization: "Product organization" publishing: "Publishing" + save: "Save" seo: "SEO" stock: "Stock" shipping: "Shipping" specifications: "Specifications" - hints: - available_on_html: "Product availability starts from the set date.
Empty date indicates no availability." - discontinue_on_html: "Product availability ends from the set date.
Empty date indicates continuous availability." - promotionable_html: "Promotions can apply to this product" - shipping_category_html: "Manage Shipping in Settings" - tax_category_html: "Manage Taxes in Settings" + view: "View online" diff --git a/admin/app/components/solidus_admin/turbo/target_frame/component.html.erb b/admin/app/components/solidus_admin/turbo/target_frame/component.html.erb new file mode 100644 index 00000000000..f73e3b094e8 --- /dev/null +++ b/admin/app/components/solidus_admin/turbo/target_frame/component.html.erb @@ -0,0 +1,5 @@ +<%= turbo_frame_tag @id do %> + <%= content %> + <%= turbo_stream.update(@source, nil) if @source %> + <%= turbo_stream.replace :flash_toasts, component("layout/flashes/toasts").new(toasts:) %> +<% end %> diff --git a/admin/app/components/solidus_admin/turbo/target_frame/component.rb b/admin/app/components/solidus_admin/turbo/target_frame/component.rb new file mode 100644 index 00000000000..24fa1e43219 --- /dev/null +++ b/admin/app/components/solidus_admin/turbo/target_frame/component.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class SolidusAdmin::Turbo::TargetFrame::Component < SolidusAdmin::BaseComponent + def initialize(id, source: nil) + @id = id + @source = source + end +end diff --git a/admin/app/components/solidus_admin/ui/forms/select/component.rb b/admin/app/components/solidus_admin/ui/forms/select/component.rb index 349e2429d2a..23a7413bdb9 100644 --- a/admin/app/components/solidus_admin/ui/forms/select/component.rb +++ b/admin/app/components/solidus_admin/ui/forms/select/component.rb @@ -30,6 +30,8 @@ class SolidusAdmin::UI::Forms::Select::Component < SolidusAdmin::BaseComponent # @param choices [Array, Array>] container with options to be rendered # (see `ActionView::Helpers::FormOptionsHelper#options_for_select`). # When +:src+ parameter is provided, use +:choices+ to provide the list of selected options only. + # Include a dataset hash `{ data: { item_label: } }` to change the text displayed in select + # box when option is selected. # @param src [nil, String] URL of a JSON resource with options data to be loaded instead of rendering options in place. # @option attributes [String] :"data-option-value-field" # @option attributes [String] :"data-option-label-field" when +:src+ param is passed, value and label of loaded options diff --git a/admin/app/controllers/solidus_admin/base_controller.rb b/admin/app/controllers/solidus_admin/base_controller.rb index bd7442f4eee..8854547389a 100644 --- a/admin/app/controllers/solidus_admin/base_controller.rb +++ b/admin/app/controllers/solidus_admin/base_controller.rb @@ -19,6 +19,7 @@ class BaseController < ApplicationController helper 'solidus_admin/components' helper 'solidus_admin/layout' + helper 'solidus_admin/flash' private diff --git a/admin/app/controllers/solidus_admin/product_option_types_controller.rb b/admin/app/controllers/solidus_admin/product_option_types_controller.rb new file mode 100644 index 00000000000..640ef324d5b --- /dev/null +++ b/admin/app/controllers/solidus_admin/product_option_types_controller.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class SolidusAdmin::ProductOptionTypesController < SolidusAdmin::BaseController + include SolidusAdmin::Moveable +end diff --git a/admin/app/controllers/solidus_admin/product_taxons_controller.rb b/admin/app/controllers/solidus_admin/product_taxons_controller.rb new file mode 100644 index 00000000000..f33cc6b14d8 --- /dev/null +++ b/admin/app/controllers/solidus_admin/product_taxons_controller.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module SolidusAdmin + class ProductTaxonsController < SolidusAdmin::BaseController + before_action :load_product, only: [:new, :create] + + def new + render component("products/show/categories/new").new(product: @product) + end + + def create + init_taxon + root_taxon! if @taxon.root? + @product.taxons << @taxon + + respond_to do |format| + format.html { redirect_to @product, status: :see_other, notice: t(".success") } + end + rescue ActiveRecord::RecordInvalid + component = component("products/show/categories/new").new(product: @product, taxon: @taxon) + respond_to do |format| + format.html { render component, status: :unprocessable_entity } + format.turbo_stream do + render turbo_stream: turbo_stream.replace(:new_product_category, component), + status: :unprocessable_entity + end + end + end + + private + + def load_product + @product = Spree::Product.friendly.find(params[:product_id]) + end + + def init_taxon + @taxon = Spree::Taxon.new(category_params) + @taxon.taxonomy_id = @taxon.parent&.taxonomy_id + end + + # Parent-less taxons must be associated with a taxonomy of the same name; it's guaranteed that in order to create a + # new parent-less taxon we need to create a new taxonomy. + def root_taxon! + # if Taxonomy.create! fails on the next step, we need validation errors on taxon object + # to display them on the form + @taxon.validate + Spree::Taxonomy.create!(name: @taxon.name, root: @taxon) + end + + def authorization_subject + Spree::Classification + end + + def category_params + params.require(:taxon).permit(:name, :parent_id, :description) + end + end +end diff --git a/admin/app/controllers/solidus_admin/products_controller.rb b/admin/app/controllers/solidus_admin/products_controller.rb index e5f22336ed3..bb5822813f8 100644 --- a/admin/app/controllers/solidus_admin/products_controller.rb +++ b/admin/app/controllers/solidus_admin/products_controller.rb @@ -43,11 +43,7 @@ def update @product = Spree::Product.friendly.find(params[:id]) if @product.update(product_params) - flash[:success] = t('spree.successfully_updated', resource: [ - Spree::Product.model_name.human, - @product.name.inspect, - ].join(' ')) - + flash[:success] = t('.success') redirect_to action: :show, status: :see_other else flash.now[:error] = @product.errors.full_messages.join(", ") diff --git a/admin/app/helpers/solidus_admin/flash_helper.rb b/admin/app/helpers/solidus_admin/flash_helper.rb new file mode 100644 index 00000000000..d25ee53023a --- /dev/null +++ b/admin/app/helpers/solidus_admin/flash_helper.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module SolidusAdmin + module FlashHelper + def toasts + flash.to_hash.with_indifferent_access.except(:alert) + end + end +end diff --git a/admin/app/javascript/solidus_admin/web_components/solidus_select.js b/admin/app/javascript/solidus_admin/web_components/solidus_select.js index 5dc1a066927..8a360cb7815 100644 --- a/admin/app/javascript/solidus_admin/web_components/solidus_select.js +++ b/admin/app/javascript/solidus_admin/web_components/solidus_select.js @@ -47,6 +47,7 @@ class SolidusSelect extends HTMLSelectElement { allowEmptyOption: true, maxOptions: null, refreshThrottle: 0, + itemLabelField: "itemLabel", plugins: { no_active_items: true, remove_button: { @@ -61,6 +62,10 @@ class SolidusSelect extends HTMLSelectElement { const message = this.input.getAttribute("data-no-results-message"); return `
${message}
`; }, + item: function(data, escape) { + const itemLabel = data[this.settings.itemLabelField] || data[this.settings.labelField]; + return `
${escape(itemLabel)}
`; + } }, }; diff --git a/admin/app/views/layouts/solidus_admin/application.html.erb b/admin/app/views/layouts/solidus_admin/application.html.erb index 6755eea3c1d..9de687890a4 100644 --- a/admin/app/views/layouts/solidus_admin/application.html.erb +++ b/admin/app/views/layouts/solidus_admin/application.html.erb @@ -31,10 +31,6 @@ - + <%= render component("layout/flashes/toasts").new(toasts:) %> diff --git a/admin/config/locales/product_taxons.en.yml b/admin/config/locales/product_taxons.en.yml new file mode 100644 index 00000000000..46959ec0b52 --- /dev/null +++ b/admin/config/locales/product_taxons.en.yml @@ -0,0 +1,6 @@ +en: + solidus_admin: + product_taxons: + title: "Categories" + create: + success: "Product category was successfully added." diff --git a/admin/config/locales/products.en.yml b/admin/config/locales/products.en.yml index fb075351261..c69e3991b14 100644 --- a/admin/config/locales/products.en.yml +++ b/admin/config/locales/products.en.yml @@ -8,3 +8,5 @@ en: success: "Products were successfully discontinued." activate: success: "Products were successfully activated." + update: + success: "Product was successfully updated." diff --git a/admin/config/routes.rb b/admin/config/routes.rb index ec215522965..b4b42671dc5 100644 --- a/admin/config/routes.rb +++ b/admin/config/routes.rb @@ -16,6 +16,8 @@ put :discontinue put :activate end + + resources :taxons, only: [:new, :create], controller: :product_taxons end # Needs a constraint to avoid interpreting "new" as a product's slug @@ -87,4 +89,5 @@ admin_resources :roles, except: [:show] admin_resources :adjustment_reasons, except: [:show] admin_resources :store_credit_reasons, except: [:show] + admin_resources :product_option_types, only: [], sortable: true end diff --git a/admin/lib/solidus_admin/testing_support/feature_helpers.rb b/admin/lib/solidus_admin/testing_support/feature_helpers.rb index 6f28ad553cb..194315f693a 100644 --- a/admin/lib/solidus_admin/testing_support/feature_helpers.rb +++ b/admin/lib/solidus_admin/testing_support/feature_helpers.rb @@ -33,6 +33,10 @@ def select_row(text) end end + def panel(title:) + find("section", text: title).find(:xpath, "..") + end + # Select options from a "solidus-select" field # # @param value [String, Array] which option(s) to select @@ -52,6 +56,14 @@ def solidus_select(value, from:) end end + def solidus_unselect(value, from:) + input = find_field(from, visible: :all) + Array.wrap(value).each do |val| + item = input.sibling("div", text: val) + item.find("a").click + end + end + def checkbox(locator) find(:checkbox, locator) end @@ -61,6 +73,20 @@ def clear_search find('button[aria-label="Clear"]').click end end + + def solidus_select_control(field) + find_field(field, visible: :all).ancestor(".control") + end + + def dialog(parent: 'body', **options) + within(parent) do + find('dialog', visible: :all, **options) + end + end + + def turbo_frame_modal + dialog(parent: find('turbo-frame', visible: :all)) + end end end end diff --git a/admin/lib/solidus_admin/testing_support/shared_examples/crud_resource_requests.rb b/admin/lib/solidus_admin/testing_support/shared_examples/crud_resource_requests.rb index 8049a450da2..f575ea0d59d 100644 --- a/admin/lib/solidus_admin/testing_support/shared_examples/crud_resource_requests.rb +++ b/admin/lib/solidus_admin/testing_support/shared_examples/crud_resource_requests.rb @@ -48,8 +48,6 @@ end context "with invalid parameters" do - let(:invalid_attributes) { { name: "", code: "", active: true } } - it "does not create a new #{resource_name.humanize}" do expect { post url_helpers.public_send("#{resource_name.pluralize}_path"), params: { resource_name => invalid_attributes } diff --git a/admin/lib/solidus_admin/testing_support/shared_examples/moveable.rb b/admin/lib/solidus_admin/testing_support/shared_examples/moveable.rb index a321ea5faa4..53fc48558c0 100644 --- a/admin/lib/solidus_admin/testing_support/shared_examples/moveable.rb +++ b/admin/lib/solidus_admin/testing_support/shared_examples/moveable.rb @@ -22,6 +22,7 @@ RSpec.shared_examples_for "features: sortable" do let(:factory_attrs) { {} } let(:scope) { "body" } + let(:handle) { nil } before do create(factory, displayed_attribute => "First", position: 1, **factory_attrs) @@ -35,7 +36,10 @@ expect(find("[data-controller='sortable']").all(:xpath, "./*").last).to have_text("Second") rows = find("[data-controller='sortable']").all(:xpath, "./*") - rows[1].drag_to rows[0] + target = rows[0] + source = rows[1] + source = source.find(handle) if handle + source.drag_to target expect(find("[data-controller='sortable']").all(:xpath, "./*").first).to have_text("Second") expect(find("[data-controller='sortable']").all(:xpath, "./*").last).to have_text("First") diff --git a/admin/spec/components/previews/solidus_admin/ui/forms/select/component_preview/overview.html.erb b/admin/spec/components/previews/solidus_admin/ui/forms/select/component_preview/overview.html.erb index 71b48288a94..82567bdbd7e 100644 --- a/admin/spec/components/previews/solidus_admin/ui/forms/select/component_preview/overview.html.erb +++ b/admin/spec/components/previews/solidus_admin/ui/forms/select/component_preview/overview.html.erb @@ -46,6 +46,16 @@ value: "1" ) %> + +
+
Alternative item label
+ <%= render current_component.new( + id: "single-alt-label", + choices: Spree::State.limit(100).map { [_1.name, _1.id, { data: { item_label: "#{_1.name} (#{_1.country.name})" } }] }, + label: "State", + name: "state" + ) %> +
diff --git a/admin/spec/features/product_spec.rb b/admin/spec/features/product_spec.rb index 396ab30a2cf..e0cbb5f88ab 100644 --- a/admin/spec/features/product_spec.rb +++ b/admin/spec/features/product_spec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'spec_helper' +require "solidus_admin/testing_support/shared_examples/moveable" describe "Product", type: :feature do before do @@ -52,4 +53,158 @@ expect(page).to have_content("Name can't be blank") expect(page).to be_axe_clean end + + describe "option types", :js do + before do + create(:option_type, name: "clothing-size", presentation: "Size").tap do |option_type| + option_type.option_values << [ + create(:option_value, name: "S", presentation: "Small"), + create(:option_value, name: "M", presentation: "Medium") + ] + end + + create(:option_type, name: "clothing-color", presentation: "Color").tap do |option_type| + option_type.option_values << [ + create(:option_value, name: "brown", presentation: "Brown"), + create(:option_value, name: "red", presentation: "Red") + ] + end + end + + let!(:product) { create(:product, name: "Just a product", slug: 'just-a-prod', price: 19.99) } + + it "updates option types" do + visit "/admin/products/just-a-prod" + solidus_select(%w[clothing-size:Size clothing-color:Color], from: "Option Types") + options_panel = panel(title: "Options") + # for some reason capybara on circle ci does not register a form submit when clicking "Save" within options panel, + # so we have to resort to Save button in the header + within("header") { click_on "Save" } + + expect(options_panel).to have_content("clothing-size:Size") + expect(options_panel).to have_content("S:Small") + expect(options_panel).to have_content("M:Medium") + expect(options_panel).to have_content("clothing-color:Color") + expect(options_panel).to have_content("brown:Brown") + expect(options_panel).to have_content("red:Red") + + solidus_unselect(%w[clothing-size:Size clothing-color:Color], from: "Option Types") + within(options_panel) { click_on "Save" } + + expect(options_panel).not_to have_content("clothing-size:Size") + expect(options_panel).not_to have_content("S:Small") + expect(options_panel).not_to have_content("M:Medium") + expect(options_panel).not_to have_content("clothing-color:Color") + expect(options_panel).not_to have_content("brown:Brown") + expect(options_panel).not_to have_content("red:Red") + end + + context "clicking on Edit" do + xit "leads to option type edit page" do + option_type = create(:option_type) + product.option_types << option_type + visit "/admin/products/just-a-prod" + + within(panel(title: "Options")) { click_on "Edit" } + expect(page).to have_current_path("/admin/option_types/#{option_type.id}/edit") + end + end + + context "clicking on Manage option types" do + it "leads to option types index page" do + visit "/admin/products/just-a-prod" + + within(panel(title: "Options")) { click_on "Manage option types" } + expect(page).to have_current_path("/admin/option_types") + end + end + + it_behaves_like "features: sortable" do + let(:product) { create(:product) } + let(:factory) { :option_type } + let(:factory_attrs) { { products: [product] } } + let(:displayed_attribute) { :name } + let(:handle) { ".handle" } + let(:path) { solidus_admin.product_path(product) } + end + end + + describe "product organization", :js do + let(:taxonomy) { create(:taxonomy, name: "Apparel") } + let(:root_taxon) { taxonomy.root } + let!(:child_taxon) { create(:taxon, name: "Caps", parent: root_taxon) } + let!(:product) { create(:product, name: "Just a product", slug: 'just-a-prod', price: 19.99) } + + describe "assigning categories" do + it "assigns product categories" do + visit "/admin/products/just-a-prod" + expect(solidus_select_control("Categories").text).to be_empty + + solidus_select %w[Apparel Caps], from: "Categories" + within("header") { click_on "Save" } + expect(page).to have_content("Product was successfully updated.") + + visit "/admin/products/just-a-prod" + expect(solidus_select_control("Categories")).to have_content("Apparel") + expect(solidus_select_control("Categories")).to have_content("Caps") + end + + it "unassigns product categories" do + product.taxons << root_taxon + + visit "/admin/products/just-a-prod" + solidus_unselect "Apparel", from: "Categories" + within("header") { click_on "Save" } + expect(page).to have_content("Product was successfully updated.") + + visit "/admin/products/just-a-prod" + expect(solidus_select_control("Categories").text).to be_empty + end + end + + context "adding new category" do + it "creates new category and assigns it to product" do + visit "/admin/products/just-a-prod" + click_on "Add new category" + expect(page).to have_content("New Category") + + within(dialog) do + fill_in "Name", with: "Jackets" + solidus_select "Apparel", from: "Parent Category" + click_on "Add Category" + end + + expect(page).to have_content("Product category was successfully added.") + expect(page).not_to have_css("dialog") + expect(page).not_to have_content("New Category") + expect(solidus_select_control("Categories")).to have_content("Jackets") + end + + context "with invalid attributes" do + context "with blank name" do + it "shows error" do + visit "/admin/products/just-a-prod" + click_on "Add new category" + within(dialog) { click_on "Add Category" } + + expect(dialog).to have_content("can't be blank") + end + end + + context "when taxon with same name already belongs to a parent" do + it "shows error" do + visit "/admin/products/just-a-prod" + click_on "Add new category" + within(dialog) do + fill_in "Name", with: child_taxon.name + solidus_select "Apparel", from: "Parent Category" + click_on "Add Category" + end + + expect(dialog).to have_content("must be unique under the same parent Taxon") + end + end + end + end + end end diff --git a/admin/spec/requests/solidus_admin/product_option_types_spec.rb b/admin/spec/requests/solidus_admin/product_option_types_spec.rb new file mode 100644 index 00000000000..efbe1c8d338 --- /dev/null +++ b/admin/spec/requests/solidus_admin/product_option_types_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "spec_helper" +require "solidus_admin/testing_support/shared_examples/moveable" + +RSpec.describe "SolidusAdmin::ProductOptionTypesController", type: :request do + it_behaves_like "requests: moveable" do + let(:factory) { :product_option_type } + let(:request_path) { solidus_admin.move_product_option_type_path(record, format: :js) } + end +end diff --git a/admin/spec/requests/solidus_admin/product_taxons_spec.rb b/admin/spec/requests/solidus_admin/product_taxons_spec.rb new file mode 100644 index 00000000000..fde4e059be1 --- /dev/null +++ b/admin/spec/requests/solidus_admin/product_taxons_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "SolidusAdmin::ProductTaxonsController", type: :request do + let(:admin_user) { create(:admin_user) } + let!(:product) { create(:product) } + + before do + allow_any_instance_of(SolidusAdmin::BaseController).to receive(:spree_current_user).and_return(admin_user) + end + + describe "GET /new" do + it "renders the new template with a 200 OK status" do + get solidus_admin.new_product_taxon_path(product) + expect(response).to have_http_status(:ok) + end + end + + describe "POST /create" do + context "when taxon belongs to a parent" do + context "with valid parameters" do + let!(:parent_taxon) { create(:taxonomy).root } + let(:valid_attributes) { { name: "Accessories", parent_id: parent_taxon.id } } + + it "creates new taxon and new classification" do + expect(Spree::Taxon.count).to eq(1) + expect(product.classifications.count).to eq(0) + + post solidus_admin.product_taxons_path(product), params: { taxon: valid_attributes } + + expect(Spree::Taxon.count).to eq(2) + expect(product.classifications.count).to eq(1) + end + + it "redirects with a 303 See Other status" do + post solidus_admin.product_taxons_path(product), params: { taxon: valid_attributes } + expect(response).to redirect_to(solidus_admin.product_path(product)) + expect(response).to have_http_status(:see_other) + end + end + + context "with invalid parameters" do + let(:invalid_attributes) { { name: "" } } + + it "does not create a new taxon" do + expect { + post solidus_admin.product_taxons_path(product), params: { taxon: invalid_attributes } + }.not_to change(Spree::Taxon, :count) + end + + it "returns unprocessable_entity status" do + post solidus_admin.product_taxons_path(product), params: { taxon: invalid_attributes } + expect(response).to have_http_status(:unprocessable_entity) + end + end + end + + context "when taxon is a root" do + context "with valid parameters" do + let(:valid_attributes) { { name: "Accessories", parent_id: nil } } + + it "creates new taxonomy, new root taxon and new classification" do + expect(Spree::Taxonomy.count).to eq(0) + expect(Spree::Taxon.count).to eq(0) + expect(product.classifications.count).to eq(0) + + post solidus_admin.product_taxons_path(product), params: { taxon: valid_attributes } + + expect(Spree::Taxonomy.count).to eq(1) + expect(Spree::Taxon.count).to eq(1) + expect(product.classifications.count).to eq(1) + end + + it "redirects with a 303 See Other status" do + post solidus_admin.product_taxons_path(product), params: { taxon: valid_attributes } + expect(response).to redirect_to(solidus_admin.product_path(product)) + expect(response).to have_http_status(:see_other) + end + end + + context "with invalid parameters" do + let!(:another_root_taxon) { create(:taxonomy, name: "Apparel").root } + let(:invalid_attributes) { { name: another_root_taxon.name, parent_id: nil } } + + it "does not create new records" do + expect(Spree::Taxonomy.count).to eq(1) + expect(Spree::Taxon.count).to eq(1) + expect(product.classifications.count).to eq(0) + + post solidus_admin.product_taxons_path(product), params: { taxon: invalid_attributes } + + expect(Spree::Taxonomy.count).to eq(1) + expect(Spree::Taxon.count).to eq(1) + expect(product.classifications.count).to eq(0) + end + + it "returns unprocessable_entity status" do + post solidus_admin.product_taxons_path(product), params: { taxon: invalid_attributes } + expect(response).to have_http_status(:unprocessable_entity) + end + end + end + end +end diff --git a/admin/spec/requests/solidus_admin/products_spec.rb b/admin/spec/requests/solidus_admin/products_spec.rb index 0addacf2c72..b2d4c1fc795 100644 --- a/admin/spec/requests/solidus_admin/products_spec.rb +++ b/admin/spec/requests/solidus_admin/products_spec.rb @@ -2,7 +2,7 @@ require "spec_helper" -RSpec.describe "SolidusAdmin::PropertiesController", type: :request do +RSpec.describe "SolidusAdmin::ProductsController", type: :request do let(:admin_user) { create(:admin_user) } before do diff --git a/admin/spec/spec_helper.rb b/admin/spec/spec_helper.rb index 38bed2afc8f..bec19ed0b0d 100644 --- a/admin/spec/spec_helper.rb +++ b/admin/spec/spec_helper.rb @@ -10,6 +10,7 @@ SimpleCov.merge_timeout(3600) SimpleCov.start('rails') do add_filter '/shared_examples/' + add_filter '/testing_support/feature_helpers' end end diff --git a/core/app/models/spree/product_option_type.rb b/core/app/models/spree/product_option_type.rb index 4c0f398ea7a..294f5b5fde1 100644 --- a/core/app/models/spree/product_option_type.rb +++ b/core/app/models/spree/product_option_type.rb @@ -5,5 +5,7 @@ class ProductOptionType < Spree::Base belongs_to :product, class_name: 'Spree::Product', inverse_of: :product_option_types, touch: true belongs_to :option_type, class_name: 'Spree::OptionType', inverse_of: :product_option_types acts_as_list scope: :product + + delegate :name, :presentation, :option_values, to: :option_type end end diff --git a/core/config/locales/en.yml b/core/config/locales/en.yml index ad8164abb08..e623d05b476 100644 --- a/core/config/locales/en.yml +++ b/core/config/locales/en.yml @@ -182,6 +182,7 @@ en: meta_title: Meta Title name: Name on_hand: On Hand + option_type_ids: Option Types price: Master Price primary_taxon: Primary Taxon primary_taxon_id: Primary Taxon @@ -190,6 +191,7 @@ en: sku: Master SKU slug: Slug tax_category: Tax Category + taxon_ids: Categories weight: Weight width: Width spree/product_property: @@ -407,6 +409,7 @@ en: meta_keywords: Meta Keywords meta_title: Meta Title name: Name + parent_id: Parent Category permalink: Permalink position: Position spree/taxonomy: diff --git a/core/lib/spree/testing_support/capybara_ext.rb b/core/lib/spree/testing_support/capybara_ext.rb index 33dd0ffc694..2b259eb1105 100644 --- a/core/lib/spree/testing_support/capybara_ext.rb +++ b/core/lib/spree/testing_support/capybara_ext.rb @@ -110,16 +110,6 @@ def find_label_by_text(text) # find the original. find('label:not(.select2-offscreen)', text: /#{Regexp.escape(text)}/i, match: :one) end - - def dialog(parent: 'body', **options) - within(parent) do - find('dialog', visible: :all, **options) - end - end - - def turbo_frame_modal - dialog(parent: find('turbo-frame', visible: :all)) - end end end end