] :value which option should be selected
# @option attributes [String] :"data-option-value-field"
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..2700198e03b
--- /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/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/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/routes.rb b/admin/config/routes.rb
index 4234dc6af3e..6b3a6d3c9f0 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
diff --git a/admin/lib/solidus_admin/testing_support/feature_helpers.rb b/admin/lib/solidus_admin/testing_support/feature_helpers.rb
index 4fe4dd88275..9166ba0be6c 100644
--- a/admin/lib/solidus_admin/testing_support/feature_helpers.rb
+++ b/admin/lib/solidus_admin/testing_support/feature_helpers.rb
@@ -88,6 +88,16 @@ def switch(locator, on: true)
checkbox = find(:label, text: locator).find(:checkbox)
on ? checkbox.check : checkbox.uncheck
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/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 d4ad0eba2d2..3fc4ff8806d 100644
--- a/admin/spec/features/product_spec.rb
+++ b/admin/spec/features/product_spec.rb
@@ -77,8 +77,15 @@
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
+
+ # FIXME: There is some kind of minor issue with this form that prevents
+ # the headless browser running in CI from successfully clicking "Save"
+ # within the options panel:
+ #
+ # within(options_panel) { click_on "Save" } # Doesn't work. :-(
+ #
+ # If you can figure out why, please resolve this FIXME! Until then,
+ # we can save using the button in the header.
within("header") { click_on "Save" }
expect(options_panel).to have_content("clothing-size:Size")
@@ -100,9 +107,7 @@
end
context "clicking on Edit" do
- # skipping test until updated option types UI is merged
- # https://github.com/solidusio/solidus/pull/6236
- xit "leads to option type edit page" do
+ it "leads to option type edit page" do
option_type = create(:option_type)
product.option_types << option_type
visit "/admin/products/just-a-prod"
@@ -130,4 +135,89 @@
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(text: "New Category")) 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
+ let(:new_category_dialog) { dialog text: "New Category" }
+ context "with blank name" do
+ it "shows error" do
+ visit "/admin/products/just-a-prod"
+ click_on "Add new category"
+
+ within new_category_dialog do
+ click_on "Add Category"
+ end
+
+ expect(new_category_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 new_category_dialog do
+ fill_in "Name", with: child_taxon.name
+ solidus_select "Apparel", from: "Parent Category"
+ click_on "Add Category"
+ end
+
+ expect(new_category_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_taxons_spec.rb b/admin/spec/requests/solidus_admin/product_taxons_spec.rb
new file mode 100644
index 00000000000..df13ee4845d
--- /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/spec_helper.rb b/admin/spec/spec_helper.rb
index 2ae8ef6a578..a4e14ba7c32 100644
--- a/admin/spec/spec_helper.rb
+++ b/admin/spec/spec_helper.rb
@@ -14,6 +14,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 0dc18316e49..9ea5400295f 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 fd2010e341f..8aa66e72f44 100644
--- a/core/config/locales/en.yml
+++ b/core/config/locales/en.yml
@@ -191,6 +191,7 @@ en:
sku: Master SKU
slug: Slug
tax_category: Tax Category
+ taxon_ids: Categories
weight: Weight
width: Width
spree/product_property:
@@ -411,6 +412,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 0f3849f6796..e5f7190c6e5 100644
--- a/core/lib/spree/testing_support/capybara_ext.rb
+++ b/core/lib/spree/testing_support/capybara_ext.rb
@@ -118,16 +118,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