diff --git a/admin/app/components/solidus_admin/orders/show/adjustments/index/component.rb b/admin/app/components/solidus_admin/orders/show/adjustments/index/component.rb new file mode 100644 index 00000000000..286efe70d6a --- /dev/null +++ b/admin/app/components/solidus_admin/orders/show/adjustments/index/component.rb @@ -0,0 +1,217 @@ +# frozen_string_literal: true + +class SolidusAdmin::Orders::Show::Adjustments::Index::Component < SolidusAdmin::UI::Pages::Index::Component + def model_class + Spree::Adjustment + end + + def back_url + solidus_admin.order_path(@order) + end + + def title + t(".title", number: @order.number) + end + + NBSP = " ".html_safe + + def initialize(order:, adjustments:) + @order = order + @adjustments = adjustments + @page = GearedPagination::Recordset.new(adjustments, per_page: adjustments.size).page(1) + end + + def batch_actions + [ + { + label: t(".actions.lock"), + action: solidus_admin.lock_order_adjustments_path(@order), + method: :put, + icon: "lock-line" + }, + { + label: t(".actions.unlock"), + action: solidus_admin.unlock_order_adjustments_path(@order), + method: :put, + icon: "lock-unlock-line" + }, + { + label: t(".actions.delete"), + action: spree.admin_order_adjustment_path(@order, '[]'), + method: :delete, + icon: "delete-bin-7-line" + }, + ] + end + + def search_key + :label_cont + end + + def search_url + solidus_admin.order_adjustments_path(@order) + end + + def columns + [ + { + header: :state, + wrap: true, + col: { class: 'w-[calc(5rem+2rem+2.5rem+1px)]' }, + data: ->(adjustment) { + if adjustment.finalized? + icon = 'lock-fill' + title = t(".state.locked") + else + icon = 'lock-unlock-line' + title = t(".state.unlocked") + end + icon_tag(icon, class: "w-5 h-5 align-middle") + tag.span(title) + } + }, + { + header: :adjustable, + col: { class: 'w-56' }, + data: ->(adjustment) { + tag.figure(safe_join([ + render(component("ui/thumbnail").for(adjustment.adjustable, class: "basis-10")), + figcaption_for_adjustable(adjustment), + ]), class: "flex items-center gap-2") + } + }, + { + header: :source, + col: { class: "w-56" }, + data: ->(adjustment) { + tag.figure(safe_join([ + figcaption_for_source(adjustment), + ]), class: "flex items-center gap-2") + } + }, + { + header: :amount, + col: { class: 'w-24' }, + data: ->(adjustment) { tag.span adjustment.display_amount.to_html, class: "grow text-right whitespace-nowrap" } + }, + { + header: tag.span(t(".actions.title"), class: 'sr-only'), + col: { class: 'w-24' }, + wrap: false, + data: ->(adjustment) do + actions = [] + + unless adjustment.source + actions << link_to( + t(".actions.edit"), + spree.edit_admin_order_adjustment_path(@order, adjustment), + class: 'body-link', + ) + end + + if adjustment.finalized? + actions << link_to( + t(".actions.unlock"), + solidus_admin.unlock_order_adjustments_path(@order, id: adjustment), + "data-turbo-method": :put, + "data-turbo-confirm": t('.confirm'), + class: 'body-link', + ) + else + actions << link_to( + t(".actions.lock"), + solidus_admin.lock_order_adjustments_path(@order, id: adjustment), + "data-turbo-method": :put, + "data-turbo-confirm": t('.confirm'), + class: 'body-link', + ) + actions << link_to( + t(".actions.delete"), + spree.admin_order_adjustment_path(@order, adjustment), + "data-turbo-method": :delete, + "data-turbo-confirm": t('.confirm'), + class: 'body-link !text-red-500', + ) + end + + render component('ui/dropdown').new( + direction: :right, + class: 'relative w-fit m-auto', + ).with_content(safe_join(actions)) + end + }, + ] + end + + def icon_thumbnail(name) + render component("ui/thumbnail").new(src: svg_data_uri(icon_tag(name))) + end + + def svg_data_uri(data) + "data:image/svg+xml;base64,#{Base64.strict_encode64(data)}" + end + + def figcaption_for_source(adjustment) + return thumbnail_caption(adjustment.label, nil) unless adjustment.source_type + + # ["Spree::PromotionAction", nil, "Spree::UnitCancel", "Spree::TaxRate"] + record = adjustment.source + record_class = adjustment.source_type&.constantize + model_name = record_class.model_name.human + + case record || record_class + when Spree::TaxRate + detail = link_to("#{model_name}: #{record.zone&.name || t('spree.all_zones')}", spree.edit_admin_tax_rate_path(adjustment.source_id), class: "body-link") + when Spree::PromotionAction + detail = link_to("#{model_name}: #{record.promotion.name}", spree.edit_admin_promotion_path(adjustment.source_id), class: "body-link") + else + detail = "#{model_name}: #{record.display_amount}" if record.respond_to?(:display_amount) + end + + thumbnail_caption(adjustment.label, detail) + end + + def figcaption_for_adjustable(adjustment) + # ["Spree::LineItem", "Spree::Order", "Spree::Shipment"] + record = adjustment.adjustable + record_class = adjustment.adjustable_type&.constantize + + case record || record_class + when Spree::LineItem + variant = record.variant + options_text = variant.options_text.presence + + description = options_text || variant.sku + detail = link_to(variant.product.name, solidus_admin.product_path(record.variant.product), class: "body-link") + when Spree::Order + description = "#{Spree::Order.model_name.human} ##{record.number}" + detail = record.display_total + when Spree::Shipment + description = "#{t('spree.shipment')} ##{record.number}" + detail = link_to(record.shipping_method.name, spree.edit_admin_shipping_method_path(record.shipping_method), class: "body-link") + when nil + # noop + else + name_method = [:display_name, :name, :number].find { record.respond_to? _1 } if record + price_method = [:display_amount, :display_total, :display_cost].find { record.respond_to? _1 } if record + + description = record_class.model_name.human + description = "#{description} - #{record.public_send(name_method)}" if name_method + + # attempt creating a link + url_options = [:admin, record, :edit, { only_path: true }] + url = begin; spree.url_for(url_options); rescue NoMethodError => e; logger.error(e.to_s); nil end + + description = link_to(description, url, class: "body-link") if url + detail = record.public_send(price_method) if price_method + end + + thumbnail_caption(description, detail) + end + + def thumbnail_caption(first_line, second_line) + tag.figcaption(safe_join([ + tag.div(first_line || NBSP, class: 'text-black body-small whitespace-nowrap text-ellipsis overflow-hidden'), + tag.div(second_line || NBSP, class: 'text-gray-500 body-small whitespace-nowrap text-ellipsis overflow-hidden') + ]), class: "flex flex-col gap-0 max-w-[15rem]") + end +end diff --git a/admin/app/components/solidus_admin/orders/show/adjustments/index/component.yml b/admin/app/components/solidus_admin/orders/show/adjustments/index/component.yml new file mode 100644 index 00000000000..544d2c90487 --- /dev/null +++ b/admin/app/components/solidus_admin/orders/show/adjustments/index/component.yml @@ -0,0 +1,21 @@ +en: + title: "Order %{number} / Adjustments" + save: "Save" + discard: "Discard" + none: "—" + + actions: + title: "Actions" + delete: "Delete" + lock: "Lock" + unlock: "Unlock" + edit: "Edit" + + state: + locked: "Locked" + unlocked: "Unlocked" + confirm: "Are you sure?" + + totals: + adjustable: "Totals (by Adjustable)" + source: "Totals (by Source)" diff --git a/admin/app/components/solidus_admin/orders/show/summary/component.html.erb b/admin/app/components/solidus_admin/orders/show/summary/component.html.erb index ba626a1a589..c801ee45a24 100644 --- a/admin/app/components/solidus_admin/orders/show/summary/component.html.erb +++ b/admin/app/components/solidus_admin/orders/show/summary/component.html.erb @@ -6,7 +6,7 @@ { label: t('.taxes'), value: number_to_currency(@order.additional_tax_total) }, { label: t('.shipping'), value: number_to_currency(@order.shipment_total) }, { label: link_to(t('.add_promo_code'), '#', class: "body-link"), value: number_to_currency(@order.promo_total) }, - { label: link_to(t('.adjustments'), '#', class: "body-link"), value: number_to_currency(@order.adjustment_total) }, + { label: link_to(t('.adjustments'), solidus_admin.order_adjustments_path(@order), class: "body-link"), value: number_to_currency(@order.adjustment_total) }, { label: t('.total'), value: number_to_currency(@order.total), class: 'font-semibold' } ] ) %> diff --git a/admin/app/components/solidus_admin/ui/pages/index/component.html.erb b/admin/app/components/solidus_admin/ui/pages/index/component.html.erb index e2b31a8c1e5..e9774650a8c 100644 --- a/admin/app/components/solidus_admin/ui/pages/index/component.html.erb +++ b/admin/app/components/solidus_admin/ui/pages/index/component.html.erb @@ -1,7 +1,7 @@ <%= page do %> <% if @tabs %> <%= page_header do %> - <%= page_header_title title %> + <%= render_title %> <% end %> <%= page_header do %> @@ -18,7 +18,7 @@ <% else %> <%= page_header do %> - <%= page_header_title title %> + <%= render_title %> <%= page_header_actions do %> <%= page_actions %> @@ -26,5 +26,15 @@ <% end %> <% end %> - <%= render_table %> + <%= page_with_sidebar do %> + <%= page_with_sidebar_main do %> + <%= render_table %> + <% end %> + + <% if sidebar %> + <%= page_with_sidebar_aside do %> + <%= sidebar %> + <% end %> + <% end %> + <% end %> <% end %> diff --git a/admin/app/components/solidus_admin/ui/pages/index/component.rb b/admin/app/components/solidus_admin/ui/pages/index/component.rb index 616a40475f8..15d31a4089e 100644 --- a/admin/app/components/solidus_admin/ui/pages/index/component.rb +++ b/admin/app/components/solidus_admin/ui/pages/index/component.rb @@ -5,9 +5,20 @@ class SolidusAdmin::UI::Pages::Index::Component < SolidusAdmin::BaseComponent Tab = Struct.new(:text, :href, :current, keyword_init: true) - def tabs - nil - end + # Template methods + def tabs; end + def model_class; end + def back_url; end + def search_key; end + def search_url; end + def page_actions; end + def sidebar; end + def sortable_options; end + def row_url(_record); end + def batch_actions; []; end + def scopes; []; end + def filters; []; end + def columns; []; end def initialize(page:) @page = page @@ -18,18 +29,10 @@ def row_fade(_record) false end - def model_class - nil - end - def title model_class.model_name.human.pluralize end - def search_key - nil - end - def search_params params[:q] end @@ -38,38 +41,10 @@ def search_name :q end - def search_url - nil - end - - def table_id - stimulus_id - end - def rows @page.records end - def row_url(_record) - nil - end - - def batch_actions - [] - end - - def scopes - [] - end - - def filters - [] - end - - def columns - [] - end - def prev_page_path solidus_admin.url_for(**request.params, page: @page.number - 1, only_path: true) unless @page.first? end @@ -91,8 +66,13 @@ def search_options } end - def sortable_options - nil + def render_title + back_url = self.back_url + + safe_join [ + (page_header_back back_url if back_url), + page_header_title(title), + ] end def render_table @@ -113,7 +93,9 @@ def render_table ) end - def page_actions - nil + def render_sidebar + sidebar = self.sidebar + + page_with_sidebar_aside { sidebar } if sidebar end end diff --git a/admin/app/controllers/solidus_admin/adjustments_controller.rb b/admin/app/controllers/solidus_admin/adjustments_controller.rb new file mode 100644 index 00000000000..6ad88acf2d8 --- /dev/null +++ b/admin/app/controllers/solidus_admin/adjustments_controller.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +class SolidusAdmin::AdjustmentsController < SolidusAdmin::BaseController + before_action :load_order + + def index + @adjustments = @order + .all_adjustments + .eligible + .order("adjustable_type ASC, created_at ASC") + .ransack(params[:q]) + .result + + set_page_and_extract_portion_from(@adjustments) + + respond_to do |format| + format.html do + render component('orders/show/adjustments/index').new( + order: @order, + adjustments: @adjustments, + ) + end + end + end + + def lock + @adjustments = @order.all_adjustments.not_finalized.where(id: params[:id]) + @adjustments.each(&:finalize!) + flash[:success] = t('.success') + + redirect_to order_adjustments_path(@order), status: :see_other + end + + def unlock + @adjustments = @order.all_adjustments.finalized.where(id: params[:id]) + @adjustments.each(&:unfinalize!) + flash[:success] = t('.success') + + redirect_to order_adjustments_path(@order), status: :see_other + end + + def destroy + @adjustments = @order.all_adjustments.where(id: params[:id]) + @adjustments.destroy_all + flash[:success] = t('.success') + + redirect_to order_adjustments_path(@order), status: :see_other + end + + private + + def load_order + @order = Spree::Order.find_by!(number: params[:order_id]) + end +end diff --git a/admin/config/locales/adjustments.en.yml b/admin/config/locales/adjustments.en.yml new file mode 100644 index 00000000000..5308b28945d --- /dev/null +++ b/admin/config/locales/adjustments.en.yml @@ -0,0 +1,10 @@ +en: + solidus_admin: + adjustments: + title: "Adjustments" + lock: + success: "Locked successfully" + unlock: + success: "Unlocked successfully" + destroy: + success: "Deleted successfully" diff --git a/admin/config/routes.rb b/admin/config/routes.rb index 49f13e99b67..c938ea27de6 100644 --- a/admin/config/routes.rb +++ b/admin/config/routes.rb @@ -26,6 +26,14 @@ admin_resources :orders, except: [ :destroy, :index ], constraints: ->{ SolidusAdmin::Config.enable_alpha_features? } do + resources :adjustments, only: [:index] do + collection do + delete :destroy + put :lock + put :unlock + end + end + resources :line_items, only: [:destroy, :create, :update] resource :customer resource :ship_address, only: [:show, :edit, :update], controller: "addresses", type: "ship" diff --git a/admin/spec/features/orders/adjustments_spec.rb b/admin/spec/features/orders/adjustments_spec.rb new file mode 100644 index 00000000000..65251226733 --- /dev/null +++ b/admin/spec/features/orders/adjustments_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe "Order", :js, type: :feature do + before { sign_in create(:admin_user, email: 'admin@example.com') } + + it "allows detaching a customer from an order" do + allow(SolidusAdmin::Config).to receive(:enable_alpha_features?) { true } + + order = create(:order, number: "R123456789", user: create(:user)) + Spree::Adjustment.insert_all([ + { + order_id: order.id, + adjustable_id: order.id, + adjustable_type: "Spree::Order", + amount: 10, + label: "Test Adjustment", + eligible: true, + finalized: false, + created_at: Time.current, + updated_at: Time.current, + included: false, + source_type: "Spree::Order", + source_id: order.id, + promotion_code_id: nil, + }, + ]) + visit "/admin/orders/R123456789" + + click_on "Adjustments" + expect(page).to have_content("Test Adjustment") + + expect(page).to be_axe_clean + + select_row("Test Adjustment") + click_on "Lock" + expect(page).to have_content("Locked successfully", wait: 5) + + select_row("Test Adjustment") + click_on "Unlock" + expect(page).to have_content("Unlocked successfully") + + select_row("Test Adjustment") + click_on "Delete" + expect(page).to have_content("Deleted successfully") + expect(page).not_to have_content("Test Adjustment") + expect(Spree::AdjustmentReason.count).to eq(0) + + expect(page).to be_axe_clean + end +end diff --git a/admin/spec/features/orders_spec.rb b/admin/spec/features/orders/index_spec.rb similarity index 100% rename from admin/spec/features/orders_spec.rb rename to admin/spec/features/orders/index_spec.rb diff --git a/admin/spec/features/order_spec.rb b/admin/spec/features/orders/show_spec.rb similarity index 100% rename from admin/spec/features/order_spec.rb rename to admin/spec/features/orders/show_spec.rb diff --git a/core/app/models/spree/adjustment.rb b/core/app/models/spree/adjustment.rb index 71673c57d5c..3ec22c90a9e 100644 --- a/core/app/models/spree/adjustment.rb +++ b/core/app/models/spree/adjustment.rb @@ -50,6 +50,8 @@ class Adjustment < Spree::Base singleton_class.deprecate :return_authorization, deprecator: Spree.deprecator + allowed_ransackable_attributes << 'label' + extend DisplayMoney money_methods :amount diff --git a/core/lib/generators/spree/dummy/templates/rails/test.rb b/core/lib/generators/spree/dummy/templates/rails/test.rb index c890b9919a2..a2ae2175dc3 100644 --- a/core/lib/generators/spree/dummy/templates/rails/test.rb +++ b/core/lib/generators/spree/dummy/templates/rails/test.rb @@ -18,7 +18,12 @@ config.eager_load = false # Raise exceptions instead of rendering exception templates - config.action_dispatch.show_exceptions = false # Should be :none for Rails 7.1+ + if Rails.gem_version >= Gem::Version.new('7.1') + config.action_controller.raise_on_missing_callback_actions = true + config.action_dispatch.show_exceptions = :none + else + config.action_dispatch.show_exceptions = false + end # Disable request forgery protection in test environment config.action_controller.allow_forgery_protection = false diff --git a/core/lib/spree/testing_support/dummy_app.rb b/core/lib/spree/testing_support/dummy_app.rb index a557949d5a8..39a246fb416 100644 --- a/core/lib/spree/testing_support/dummy_app.rb +++ b/core/lib/spree/testing_support/dummy_app.rb @@ -49,11 +49,6 @@ def self.setup(gem_root:, lib_name:, auto_migrate: true) class Application < ::Rails::Application config.load_defaults("#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}") - if Rails.gem_version >= Gem::Version.new('7.1') - config.action_controller.raise_on_missing_callback_actions = true - config.action_dispatch.show_exceptions = :none - end - # Make the test environment more production-like: config.action_controller.allow_forgery_protection = false config.action_controller.default_protect_from_forgery = false @@ -71,8 +66,13 @@ class Application < ::Rails::Application config.cache_classes = true # Make debugging easier: + if Rails.gem_version >= Gem::Version.new('7.1') + config.action_controller.raise_on_missing_callback_actions = true + config.action_dispatch.show_exceptions = :none + else + config.action_dispatch.show_exceptions = false + end config.consider_all_requests_local = true - config.action_dispatch.show_exceptions = false # Should be :none for Rails 7.1+ config.active_support.deprecation = :stderr config.log_level = :debug diff --git a/tasks/linting.rake b/tasks/linting.rake index 2d8b1c6993f..52f81816492 100644 --- a/tasks/linting.rake +++ b/tasks/linting.rake @@ -4,7 +4,7 @@ namespace :lint do task :rb do ci_options = "-f junit -o '#{__dir__}/../test-results/rubocop-results.xml' " if ENV['CI'] - sh %{bundle exec rubocop -P -f q #{ci_options}$(git ls-files -co --exclude-standard | grep -E "\\.rb$" | grep -v "/templates/")} + sh %{bundle exec rubocop -P -f clang #{ci_options}$(git ls-files -co --exclude-standard | grep -E "\\.rb$" | grep -v "/templates/")} end task :erb do