diff --git a/admin/app/components/solidus_admin/base_component.rb b/admin/app/components/solidus_admin/base_component.rb index 49d3f27272b..8afab764980 100644 --- a/admin/app/components/solidus_admin/base_component.rb +++ b/admin/app/components/solidus_admin/base_component.rb @@ -10,7 +10,9 @@ class BaseComponent < ViewComponent::Base include SolidusAdmin::StimulusHelper include SolidusAdmin::VoidElementsHelper include SolidusAdmin::SolidusFormHelper - 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/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..d0cb563eb2b --- /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 21e2fd3d589..62804b80c6b 100644 --- a/admin/app/components/solidus_admin/products/show/component.html.erb +++ b/admin/app/components/solidus_admin/products/show/component.html.erb @@ -145,8 +145,22 @@ <%= f.checkbox(:promotionable, hint: t(".hints.promotionable_html")) %> <% end %> - <%= render component("ui/panel").new(title: t(".product_organization")) do %> - <%= f.select(:taxon_ids, taxon_options, multiple: true) %> + <%= render component("ui/panel").new(title: t(".product_organization")) do |panel| %> + <%= hidden_field_tag "#{f.object_name}[taxon_ids][]", 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 %> <% end %> @@ -166,3 +180,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 bec4d2a2735..c80fdba8066 100644 --- a/admin/app/components/solidus_admin/products/show/component.rb +++ b/admin/app/components/solidus_admin/products/show/component.rb @@ -16,7 +16,7 @@ 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 diff --git a/admin/app/components/solidus_admin/products/show/component.yml b/admin/app/components/solidus_admin/products/show/component.yml index 9d9e3855b6f..1c992f78635 100644 --- a/admin/app/components/solidus_admin/products/show/component.yml +++ b/admin/app/components/solidus_admin/products/show/component.yml @@ -1,4 +1,5 @@ en: + add_new_category: "Add new category" back: "Back" delete: "Delete" delete_confirmation: "Are you sure you want to delete this product?" 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 758ece34949..40d560f1fc8 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 [nil, String, Integer, Array] :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