diff --git a/admin/app/components/solidus_admin/orders/cart/component.html.erb b/admin/app/components/solidus_admin/orders/cart/component.html.erb new file mode 100644 index 00000000000..9972e68942d --- /dev/null +++ b/admin/app/components/solidus_admin/orders/cart/component.html.erb @@ -0,0 +1,118 @@ +
-products-url-value="<%= solidus_admin.variants_for_order_path(@order) %>" + data-action=" + keydown-><%= stimulus_id %>#selectResult + " + data-<%= stimulus_id %>-loading-text-value="<%= t('.loading') %>" + data-<%= stimulus_id %>-initial-text-value="<%= t('.initial') %>" + data-<%= stimulus_id %>-empty-text-value="<%= t('.empty') %>" +> + <%= render component('ui/panel').new do |panel| %> +
+
+ <%= render component("ui/forms/search_field").new( + "data-action": "#{stimulus_id}#search focus->#{stimulus_id}#showResults #{stimulus_id}#showResults", + placeholder: t(".search_placeholder"), + "data-#{stimulus_id}-target": "searchField", + ) %> +
+ + <%# results popover %> +
+ +
-target="results" + > +
+
+ +
+ + <%# line items table %> +
+ + + + + + + + + + + <% @order.line_items.each do |line_item| %> + + + + + + + <% end %> + +
ProductQuantityPriceActions
+
+ <% variant = line_item.variant %> + <%= render component("ui/thumbnail").new( + src: (variant.images.first || variant.product.gallery.images.first)&.url(:small), + alt: variant.name + ) %> +
+
<%= variant.name %>
+
+ SKU: <%= variant.sku %> + <%= variant.options_text.presence&.prepend("- ") %> +
+
+
+
+ <%= form_for(line_item, url: solidus_admin.order_line_item_path(@order, line_item), html: { + "data-controller": "readonly-when-submitting" + }) do |f| %> + <%= render component("ui/forms/input").new( + name: "#{f.object_name}[quantity]", + type: :number, + value: line_item.quantity, + "aria-label": "Quantity", + min: 0, + class: "!w-16 inline-block", + "data-action": "input->#{stimulus_id}#updateLineItem", + ) %> + <% render component("ui/button").new(type: :submit, text: "Update", class: "inline-block") %> + <% end %> + + <%= line_item.single_money.to_html %> + + <%= form_for(line_item, url: solidus_admin.order_line_item_path(@order, line_item), method: :delete) do |f| %> + <%= render component('ui/button').new( + scheme: :ghost, + size: :s, + title: t("spree.delete"), + icon: 'close-line', + "data-controller": "confirm", + "data-confirm-text-value": t("spree.are_you_sure"), + ) %> + <% end %> +
+
+ + <% end %> +
diff --git a/admin/app/components/solidus_admin/orders/cart/component.js b/admin/app/components/solidus_admin/orders/cart/component.js new file mode 100644 index 00000000000..0d1fd7441d8 --- /dev/null +++ b/admin/app/components/solidus_admin/orders/cart/component.js @@ -0,0 +1,135 @@ +import { Controller } from "@hotwired/stimulus" +import { useClickOutside, useDebounce } from "stimulus-use" + +const QUERY_KEY = "q[name_or_variants_including_master_sku_cont]" + +export default class extends Controller { + static targets = ["result", "results", "searchField"] + static values = { + results: String, + productsUrl: String, + loadingText: String, + initialText: String, + emptyText: String, + } + static debounces = [ + { + name: "requestSubmitForLineItems", + wait: 500, + }, + "search", + ] + + get query() { + return this.searchFieldTarget.value + } + + get selectedResult() { + // Keep the index within boundaries + if (this.selectedIndex < 0) this.selectedIndex = 0 + if (this.selectedIndex >= this.resultTargets.length) this.selectedIndex = this.resultTargets.length - 1 + + return this.resultTargets[this.selectedIndex] + } + + connect() { + useClickOutside(this) + useDebounce(this) + + this.selectedIndex = 0 + this.lineItemsToBeSubmitted = [] + + if (this.query) { + this.showResults() + this.search() + } + } + + selectResult(event) { + switch (event.key) { + case "Enter": + event.preventDefault() + this.selectedResult?.click() + break + case "ArrowUp": + event.preventDefault() + this.selectedIndex -= 1 + this.render() + break + case "ArrowDown": + event.preventDefault() + this.selectedIndex += 1 + this.render() + break + } + } + + clickOutside() { + this.openResults = false + this.render() + } + + async search() { + const query = this.query + + if (query) { + this.resultsValue = this.loadingTextValue + this.render() + + this.resultsValue = (await (await fetch(`${this.productsUrlValue}?${QUERY_KEY}=${query}`)).text()) || this.emptyTextValue + this.render() + } else { + this.resultsValue = this.initialTextValue + this.render() + } + } + + showResults() { + this.openResults = true + this.render() + } + + updateLineItem(event) { + if (!this.lineItemsToBeSubmitted.includes(event.currentTarget)) { + this.lineItemsToBeSubmitted.push(event.currentTarget) + } + + this.requestSubmitForLineItems() + } + + // This is a workaround to permit using debounce when needing to pass a parameter + requestSubmitForLineItems() { + this.lineItemsToBeSubmitted.forEach((lineItem) => { + lineItem.form.requestSubmit() + }) + this.lineItemsToBeSubmitted = [] + } + + render() { + let resultsHtml = this.resultsValue + + if (this.renderedHtml !== resultsHtml) { + this.renderedHtml = resultsHtml + this.resultsTarget.innerHTML = resultsHtml + } + + if (this.openResults && resultsHtml && this.query) { + if (!this.resultsTarget.parentNode.open) this.selectedIndex = 0 + + for (const result of this.resultTargets) { + if (result === this.selectedResult) { + if (!result.hasAttribute("aria-selected") && result.scrollIntoViewIfNeeded) { + // This is a non-standard method, but it's supported by all major browsers + result.scrollIntoViewIfNeeded() + } + result.setAttribute("aria-selected", true) + } else { + result.removeAttribute("aria-selected") + } + } + this.resultsTarget.parentNode.open = true + } else { + this.resultsTarget.parentNode.open = false + } + } +} diff --git a/admin/app/components/solidus_admin/orders/cart/component.rb b/admin/app/components/solidus_admin/orders/cart/component.rb new file mode 100644 index 00000000000..ccb596efba0 --- /dev/null +++ b/admin/app/components/solidus_admin/orders/cart/component.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class SolidusAdmin::Orders::Cart::Component < SolidusAdmin::BaseComponent + def initialize(order:) + @order = order + end +end diff --git a/admin/app/components/solidus_admin/orders/cart/component.yml b/admin/app/components/solidus_admin/orders/cart/component.yml new file mode 100644 index 00000000000..c5f51690154 --- /dev/null +++ b/admin/app/components/solidus_admin/orders/cart/component.yml @@ -0,0 +1,7 @@ +# Add your component translations here. +# Use the translation in the example in your template with `t(".hello")`. +en: + search_placeholder: "Find a variant by name or SKU" + loading: "Loading..." + initial: "Type to search" + empty: "No results" diff --git a/admin/app/components/solidus_admin/orders/cart/result/component.html.erb b/admin/app/components/solidus_admin/orders/cart/result/component.html.erb new file mode 100644 index 00000000000..8fbdf383ac6 --- /dev/null +++ b/admin/app/components/solidus_admin/orders/cart/result/component.html.erb @@ -0,0 +1,34 @@ +<%= form_for(@order.line_items.build(variant: @variant), url: solidus_admin.order_line_items_path(@order), method: :post, html: { + "data-controller": "readonly-when-submitting #{stimulus_id}", + "data-action": "click->#{stimulus_id}#submit", + "data-#{component('orders/cart').stimulus_id}-target": "result", + class: " + rounded + p-2 + items-center + flex + hover:bg-gray-25 + aria-selected:bg-gray-25 + cursor-pointer + " +}) do |f| %> + <%= hidden_field_tag("#{f.object_name}[variant_id]", @variant.id) %> +
+ <%= render component("ui/thumbnail").new( + src: @image&.url(:small), + alt: @variant.name + ) %> +
+
<%= @variant.name %>
+
+ SKU: + <%= @variant.sku %> + <%= @variant.options_text.presence&.prepend("- ") %> +
+
+
+
+ <%= render component("products/stock").from_variant(@variant) %> + <%= @variant.display_price.to_html %> +
+<% end %> diff --git a/admin/app/components/solidus_admin/orders/cart/result/component.js b/admin/app/components/solidus_admin/orders/cart/result/component.js new file mode 100644 index 00000000000..6778c98c6da --- /dev/null +++ b/admin/app/components/solidus_admin/orders/cart/result/component.js @@ -0,0 +1,7 @@ +import { Controller } from '@hotwired/stimulus' + +export default class extends Controller { + submit(event) { + event.currentTarget.requestSubmit() + } +} diff --git a/admin/app/components/solidus_admin/orders/cart/result/component.rb b/admin/app/components/solidus_admin/orders/cart/result/component.rb new file mode 100644 index 00000000000..fe92d1a9665 --- /dev/null +++ b/admin/app/components/solidus_admin/orders/cart/result/component.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class SolidusAdmin::Orders::Cart::Result::Component < SolidusAdmin::BaseComponent + with_collection_parameter :variant + + def initialize(order:, variant:) + @order = order + @variant = variant + @image = @variant.images.first || @variant.product.gallery.images.first + end +end diff --git a/admin/app/components/solidus_admin/orders/show/component.html.erb b/admin/app/components/solidus_admin/orders/show/component.html.erb index c420dd6e9c9..fc5ecff6a5e 100644 --- a/admin/app/components/solidus_admin/orders/show/component.html.erb +++ b/admin/app/components/solidus_admin/orders/show/component.html.erb @@ -7,4 +7,6 @@ <%= render component("ui/button").new(tag: :button, text: t(".save"), form: form_id) %> <% end %> <% end %> + + <%= render component('orders/cart').new(order: @order) %> <% end %> diff --git a/admin/app/controllers/solidus_admin/line_items_controller.rb b/admin/app/controllers/solidus_admin/line_items_controller.rb new file mode 100644 index 00000000000..e400bae7546 --- /dev/null +++ b/admin/app/controllers/solidus_admin/line_items_controller.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module SolidusAdmin + class LineItemsController < SolidusAdmin::BaseController + def destroy + load_order + load_line_item + + @line_item.destroy! + + redirect_to cart_order_path(@order), status: :see_other, notice: t('.success') + end + + def create + load_order + variant_id = params.require(:line_item).require(:variant_id) + @variant = Spree::Variant.find(variant_id) + @line_item = @order.contents.add(@variant) + + redirect_to cart_order_path(@order), status: :see_other, notice: t('.success') + end + + def update + load_order + load_line_item + + desired_quantity = params[:line_item][:quantity].to_i + + @line_item = @order.contents.add(@line_item.variant, desired_quantity - @line_item.quantity) + + redirect_to cart_order_path(@order), status: :see_other, notice: t('.success') + end + + private + + def load_order + @order = Spree::Order.find_by!(number: params[:order_id]) + authorize! action_name, @order + end + + def load_line_item + @line_item = @order.line_items.find(params[:id]) + end + end +end diff --git a/admin/app/controllers/solidus_admin/orders_controller.rb b/admin/app/controllers/solidus_admin/orders_controller.rb index de463f89d75..4ebb1af3310 100644 --- a/admin/app/controllers/solidus_admin/orders_controller.rb +++ b/admin/app/controllers/solidus_admin/orders_controller.rb @@ -26,6 +26,33 @@ def show end end + def variants_for + load_order + + # We need to eager load active storage attachments when using it + if Spree::Image.include?(Spree::Image::ActiveStorageAttachment) + image_includes = { + attachment_attachment: { blob: { variant_records: { image_attachment: :blob } } } + } + end + + @variants = Spree::Variant + .where.not(id: @order.line_items.select(:variant_id)) + .order(created_at: :desc, id: :desc) + .where(product_id: Spree::Product.ransack(params[:q]).result.select(:id)) + .limit(10) + .eager_load( + :prices, + images: image_includes || {}, + option_values: :option_type, + stock_items: :stock_location, + ) + + respond_to do |format| + format.html { render component('orders/cart/result').with_collection(@variants, order: @order), layout: false } + end + end + private def load_order diff --git a/admin/config/locales/line_items.en.yml b/admin/config/locales/line_items.en.yml new file mode 100644 index 00000000000..1e81909ed54 --- /dev/null +++ b/admin/config/locales/line_items.en.yml @@ -0,0 +1,9 @@ +en: + solidus_admin: + line_items: + update: + success: "Quantity updated successfully" + create: + success: "Variant added to cart successfully" + destroy: + success: "Line item removed successfully" diff --git a/admin/config/routes.rb b/admin/config/routes.rb index 0cc1c3329bc..f40cb228d22 100644 --- a/admin/config/routes.rb +++ b/admin/config/routes.rb @@ -14,8 +14,11 @@ end end resources :orders, only: [:index] do + resources :line_items, only: [:destroy, :create, :update] + member do get :cart, to: "orders#show" + get :variants_for end end end diff --git a/admin/spec/features/order_spec.rb b/admin/spec/features/order_spec.rb new file mode 100644 index 00000000000..95dff2483ba --- /dev/null +++ b/admin/spec/features/order_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe "Order", type: :feature do + before { sign_in create(:admin_user, email: 'admin@example.com') } + + context "in cart state" do + it "allows managing the cart", :js do + create(:product, name: "Just a product", slug: 'just-a-prod', price: 19.99) + create(:product, name: "Just another product", slug: 'just-another-prod', price: 29.99) + create(:order, number: "R123456789", total: 19.99, state: "cart") + + visit "/admin/orders/R123456789/cart" + + expect(page).to have_content("Order R123456789") + + search_field = find("[data-#{SolidusAdmin::Orders::Cart::Component.stimulus_id}-target='searchField']") + search_field.set "another" + + expect(page).not_to have_content("Just a product") + expect(page).to have_content("Just another product") + + expect(Spree::Order.last.line_items.count).to eq(0) + + find("[aria-selected]", text: "Just another product").click + expect(page).to have_content("Variant added to cart successfully", wait: 30) + + expect(Spree::Order.last.line_items.count).to eq(1) + expect(Spree::Order.last.line_items.last.quantity).to eq(1) + + fill_in "line_item[quantity]", with: 4 + expect(page).to have_content("Quantity updated successfully", wait: 30) + + expect(Spree::Order.last.line_items.last.quantity).to eq(4) + + accept_confirm("Are you sure?") { click_on "Delete" } + expect(page).to have_content("Line item removed successfully", wait: 30) + + expect(Spree::Order.last.line_items.count).to eq(0) + expect(page).to be_axe_clean + end + end +end