From b3624089c1404b870b84df34b3017d4c0f3d6039 Mon Sep 17 00:00:00 2001 From: Rainer Dema Date: Mon, 4 Dec 2023 16:59:43 +0100 Subject: [PATCH 1/4] Add ransackable association for `Spree::StockItem` search in admin Enhanced the search capabilities in the admin panel by updating the `allowed_ransackable_associations` of the `Spree::StockItem` model. This update specifically adds the `variant` association to the list of associations that can be searched using Ransack. --- core/app/models/spree/stock_item.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/core/app/models/spree/stock_item.rb b/core/app/models/spree/stock_item.rb index d92831d85b1..506cf5aa1fb 100644 --- a/core/app/models/spree/stock_item.rb +++ b/core/app/models/spree/stock_item.rb @@ -21,6 +21,7 @@ class StockItem < Spree::Base after_touch { variant.touch } self.allowed_ransackable_attributes = ['count_on_hand', 'stock_location_id'] + self.allowed_ransackable_associations = %w[variant] # @return [Array] the backordered inventory units # associated with this stock item From 33cba691ecd634e0c3abf1a07b66a2f0c184f613 Mon Sep 17 00:00:00 2001 From: Rainer Dema Date: Mon, 4 Dec 2023 17:00:43 +0100 Subject: [PATCH 2/4] Add `stock_items/index` component with dedicated actions --- .../stock_items/index/component.html.erb | 25 +++ .../stock_items/index/component.rb | 153 ++++++++++++++++++ .../stock_items/index/component.yml | 10 ++ .../solidus_admin/stock_items_controller.rb | 26 +++ admin/config/locales/stock_items.en.yml | 4 + admin/config/routes.rb | 1 + admin/lib/solidus_admin/configuration.rb | 6 + admin/spec/features/stock_items_spec.rb | 56 +++++++ 8 files changed, 281 insertions(+) create mode 100644 admin/app/components/solidus_admin/stock_items/index/component.html.erb create mode 100644 admin/app/components/solidus_admin/stock_items/index/component.rb create mode 100644 admin/app/components/solidus_admin/stock_items/index/component.yml create mode 100644 admin/app/controllers/solidus_admin/stock_items_controller.rb create mode 100644 admin/config/locales/stock_items.en.yml create mode 100644 admin/spec/features/stock_items_spec.rb diff --git a/admin/app/components/solidus_admin/stock_items/index/component.html.erb b/admin/app/components/solidus_admin/stock_items/index/component.html.erb new file mode 100644 index 00000000000..2893cb50785 --- /dev/null +++ b/admin/app/components/solidus_admin/stock_items/index/component.html.erb @@ -0,0 +1,25 @@ +<%= page do %> + <%= page_header do %> + <%= page_header_title title %> + <% end %> + + <%= render component('ui/table').new( + id: stimulus_id, + data: { + class: Spree::StockItem, + rows: @page.records, + prev: prev_page_path, + next: next_page_path, + columns: columns, + batch_actions: batch_actions, + }, + search: { + name: :q, + value: params[:q], + url: solidus_admin.stock_items_path, + searchbar_key: :variant_product_name_or_variant_sku_or_variant_option_values_name_or_variant_option_values_presentation_cont, + filters: filters, + scopes: scopes, + }, + ) %> +<% end %> diff --git a/admin/app/components/solidus_admin/stock_items/index/component.rb b/admin/app/components/solidus_admin/stock_items/index/component.rb new file mode 100644 index 00000000000..ed3b4e52b10 --- /dev/null +++ b/admin/app/components/solidus_admin/stock_items/index/component.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +class SolidusAdmin::StockItems::Index::Component < SolidusAdmin::BaseComponent + include SolidusAdmin::Layout::PageHelpers + + def initialize(page:) + @page = page + end + + def title + Spree::StockItem.model_name.human.pluralize + end + + def prev_page_path + solidus_admin.url_for(**request.params, page: @page.number - 1, only_path: true) unless @page.first? + end + + def next_page_path + solidus_admin.url_for(**request.params, page: @page.next_param, only_path: true) unless @page.last? + end + + def batch_actions + [] + end + + def scopes + [ + { label: t('.scopes.all_stock_items'), name: 'all', default: true }, + { label: t('.scopes.back_orderable'), name: 'back_orderable' }, + { label: t('.scopes.out_of_stock'), name: 'out_of_stock' }, + { label: t('.scopes.low_stock'), name: 'low_stock' }, + { label: t('.scopes.in_stock'), name: 'in_stock' }, + ] + end + + def filters + [ + { + presentation: t('.filters.stock_locations'), + combinator: 'or', + attribute: "stock_location_id", + predicate: "eq", + options: Spree::StockLocation.all.map do |stock_location| + [ + stock_location.name.titleize, + stock_location.id + ] + end + }, + { + presentation: t('.filters.variants'), + combinator: 'or', + attribute: "variant_id", + predicate: "eq", + options: Spree::Variant.all.map do |variant| + [ + variant.descriptive_name, + variant.id + ] + end + }, + ] + end + + def columns + [ + image_column, + name_column, + sku_column, + variant_column, + stock_location_column, + back_orderable_column, + count_on_hand_column, + ] + end + + def image_column + { + col: { class: "w-[72px]" }, + header: tag.span('aria-label': t('.image'), role: 'text'), + data: ->(stock_item) do + image = stock_item.variant.gallery.images.first or return + + render( + component('ui/thumbnail').new( + src: image.url(:small), + alt: stock_item.variant.name + ) + ) + end + } + end + + def name_column + { + header: :name, + data: ->(stock_item) do + content_tag :div, stock_item.variant.name + end + } + end + + def sku_column + { + header: :sku, + data: ->(stock_item) do + content_tag :div, stock_item.variant.sku + end + } + end + + def variant_column + { + header: :variant, + data: ->(stock_item) do + content_tag(:div, class: "space-y-0.5") do + safe_join( + stock_item.variant.option_values.sort_by(&:option_type_name).map do |option_value| + render(component('ui/badge').new(name: "#{option_value.option_type_presentation}: #{option_value.presentation}")) + end + ) + end + end + } + end + + def stock_location_column + { + header: :stock_location, + data: ->(stock_item) do + link_to stock_item.stock_location.name, spree.admin_stock_location_stock_movements_path(stock_item.stock_location.id, q: { variant_sku_eq: stock_item.variant.sku }) + end + } + end + + def back_orderable_column + { + header: :back_orderable, + data: ->(stock_item) do + stock_item.backorderable? ? component('ui/badge').yes : component('ui/badge').no + end + } + end + + def count_on_hand_column + { + header: :count_on_hand, + data: ->(stock_item) do + content_tag :div, stock_item.count_on_hand + end + } + end +end diff --git a/admin/app/components/solidus_admin/stock_items/index/component.yml b/admin/app/components/solidus_admin/stock_items/index/component.yml new file mode 100644 index 00000000000..f8a351aad69 --- /dev/null +++ b/admin/app/components/solidus_admin/stock_items/index/component.yml @@ -0,0 +1,10 @@ +en: + filters: + stock_locations: Stock Locations + variants: Variants + scopes: + all_stock_items: All + back_orderable: Back Orderable + out_of_stock: Out Of Stock + low_stock: Low Stock + in_stock: In Stock diff --git a/admin/app/controllers/solidus_admin/stock_items_controller.rb b/admin/app/controllers/solidus_admin/stock_items_controller.rb new file mode 100644 index 00000000000..ed74b0ea2a8 --- /dev/null +++ b/admin/app/controllers/solidus_admin/stock_items_controller.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module SolidusAdmin + class StockItemsController < SolidusAdmin::BaseController + include SolidusAdmin::ControllerHelpers::Search + + search_scope(:all, default: true) { _1 } + search_scope(:back_orderable) { _1.where(backorderable: true) } + search_scope(:out_of_stock) { _1.where('count_on_hand <= 0') } + search_scope(:low_stock) { _1.where('count_on_hand > 0 AND count_on_hand < ?', SolidusAdmin::Config[:low_stock_value]) } + search_scope(:in_stock) { _1.where('count_on_hand > 0') } + + def index + stock_items = apply_search_to( + Spree::StockItem.order(created_at: :desc, id: :desc), + param: :q, + ) + + set_page_and_extract_portion_from(stock_items) + + respond_to do |format| + format.html { render component('stock_items/index').new(page: @page) } + end + end + end +end diff --git a/admin/config/locales/stock_items.en.yml b/admin/config/locales/stock_items.en.yml new file mode 100644 index 00000000000..b1ad9f018e2 --- /dev/null +++ b/admin/config/locales/stock_items.en.yml @@ -0,0 +1,4 @@ +en: + solidus_admin: + stock_items: + title: "Stock Items" diff --git a/admin/config/routes.rb b/admin/config/routes.rb index f28ab79387b..3380e1fb21d 100644 --- a/admin/config/routes.rb +++ b/admin/config/routes.rb @@ -40,4 +40,5 @@ admin_resources :tax_categories, only: [:index, :destroy] admin_resources :tax_rates, only: [:index, :destroy] admin_resources :payment_methods, only: [:index, :destroy], sortable: true + admin_resources :stock_items, only: [:index] end diff --git a/admin/lib/solidus_admin/configuration.rb b/admin/lib/solidus_admin/configuration.rb index cbbf6b40077..a18a4bc74a4 100644 --- a/admin/lib/solidus_admin/configuration.rb +++ b/admin/lib/solidus_admin/configuration.rb @@ -93,6 +93,12 @@ class Configuration < Spree::Preferences::Configuration # meaning it will search by product name or product variants sku. preference :product_search_key, :string, default: :name_or_variants_including_master_sku_cont + # @!attribute [rw] low_stock_value + # @return [Integer] The low stock value determines the threshold at which products are considered low in stock. + # Products with a count_on_hand less than or equal to this value will be considered low in stock. + # Default: 10 + preference :low_stock_value, :integer, default: 10 + preference :storefront_product_path_proc, :proc, default: ->(_version) { ->(product) { "/products/#{product.slug}" } } diff --git a/admin/spec/features/stock_items_spec.rb b/admin/spec/features/stock_items_spec.rb new file mode 100644 index 00000000000..85c1607d6c3 --- /dev/null +++ b/admin/spec/features/stock_items_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe "Stock Items", :js, type: :feature do + before { sign_in create(:admin_user, email: 'admin@example.com') } + + it "lists stock items and allows navigating through scopes" do + non_backorderable = create(:stock_item, backorderable: false) + backorderable = create(:stock_item, backorderable: true) + out_of_stock = begin + item = create(:stock_item, backorderable: false) + item.reduce_count_on_hand_to_zero + item + end + low_stock = begin + item = create(:stock_item, backorderable: false) + item.set_count_on_hand(SolidusAdmin::Config[:low_stock_value] - 1) + item + end + + visit "/admin/stock_items" + + # `All` default scope + expect(page).to have_content(non_backorderable.variant.sku) + expect(page).to have_content(backorderable.variant.sku) + expect(page).to have_content(out_of_stock.variant.sku) + expect(page).to have_content(low_stock.variant.sku) + + click_on 'Back Orderable' + expect(page).to_not have_content(non_backorderable.variant.sku) + expect(page).to have_content(backorderable.variant.sku) + expect(page).to_not have_content(out_of_stock.variant.sku) + expect(page).to_not have_content(low_stock.variant.sku) + + click_on 'Out Of Stock' + expect(page).to_not have_content(non_backorderable.variant.sku) + expect(page).to_not have_content(backorderable.variant.sku) + expect(page).to have_content(out_of_stock.variant.sku) + expect(page).to_not have_content(low_stock.variant.sku) + + click_on 'Low Stock' + expect(page).to_not have_content(non_backorderable.variant.sku) + expect(page).to_not have_content(backorderable.variant.sku) + expect(page).to_not have_content(out_of_stock.variant.sku) + expect(page).to have_content(low_stock.variant.sku) + + click_on 'In Stock' + expect(page).to have_content(non_backorderable.variant.sku) + expect(page).to have_content(backorderable.variant.sku) + expect(page).to_not have_content(out_of_stock.variant.sku) + expect(page).to have_content(low_stock.variant.sku) + + expect(page).to be_axe_clean + end +end From 357d17f0bc9cfc8be538ba7462fc25b3482c34bf Mon Sep 17 00:00:00 2001 From: Rainer Dema Date: Mon, 4 Dec 2023 17:14:13 +0100 Subject: [PATCH 3/4] Add conditional focus behavior to search field after filter selection --- admin/app/components/solidus_admin/ui/table/component.js | 5 +++-- .../solidus_admin/ui/table/ransack_filter/component.js | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/admin/app/components/solidus_admin/ui/table/component.js b/admin/app/components/solidus_admin/ui/table/component.js index db605cca934..47065712993 100644 --- a/admin/app/components/solidus_admin/ui/table/component.js +++ b/admin/app/components/solidus_admin/ui/table/component.js @@ -42,10 +42,11 @@ export default class extends Controller { } } - showSearch(event) { + showSearch({ detail: { avoidFocus } }) { this.modeValue = "search" this.render() - this.searchFieldTarget.focus() + + if (!avoidFocus) this.searchFieldTarget.focus() } search() { diff --git a/admin/app/components/solidus_admin/ui/table/ransack_filter/component.js b/admin/app/components/solidus_admin/ui/table/ransack_filter/component.js index 106133d0292..fdbc336f8b1 100644 --- a/admin/app/components/solidus_admin/ui/table/ransack_filter/component.js +++ b/admin/app/components/solidus_admin/ui/table/ransack_filter/component.js @@ -29,8 +29,9 @@ export default class extends Controller { } showSearch() { - if (this.isAnyCheckboxChecked()) - this.dispatch("showSearch") + if (this.isAnyCheckboxChecked()) { + this.dispatch("showSearch", { detail: { avoidFocus: true } }) + } } filterOptions(event) { From 349502f516f0b124b61a354c7197496268ffdeed Mon Sep 17 00:00:00 2001 From: Rainer Dema Date: Mon, 4 Dec 2023 17:27:33 +0100 Subject: [PATCH 4/4] Remove extra blank line in filter configuration --- admin/app/components/solidus_admin/orders/index/component.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/admin/app/components/solidus_admin/orders/index/component.rb b/admin/app/components/solidus_admin/orders/index/component.rb index e1d4e39c523..1e5cf38a5a7 100644 --- a/admin/app/components/solidus_admin/orders/index/component.rb +++ b/admin/app/components/solidus_admin/orders/index/component.rb @@ -47,7 +47,6 @@ def filters ] end }, - { presentation: t('.filters.shipment_state'), combinator: 'or',