From df597043811cf243fecd8e9b8fc82c5c6bd779a6 Mon Sep 17 00:00:00 2001 From: Rainer Dema Date: Mon, 4 Dec 2023 17:00:43 +0100 Subject: [PATCH] Add `stock_items/index` component with dedicated actions --- .../stock_items/index/component.html.erb | 25 +++ .../stock_items/index/component.rb | 157 ++++++++++++++++++ .../stock_items/index/component.yml | 12 ++ .../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, 287 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..b05138cae5d --- /dev/null +++ b/admin/app/components/solidus_admin/stock_items/index/component.rb @@ -0,0 +1,157 @@ +# 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 + if stock_item.backorderable? + component('ui/badge').new(name: t('.yes'), color: :green) + else + component('ui/badge').new(name: t('.no'), color: :graphite_light) + end + 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..a34a7fcb4d9 --- /dev/null +++ b/admin/app/components/solidus_admin/stock_items/index/component.yml @@ -0,0 +1,12 @@ +en: + "yes": "Yes" + "no": "No" + 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..45a1862386e --- /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(5) + 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