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/index/component.html.erb b/admin/app/components/solidus_admin/orders/index/component.html.erb index 6539a00b9e7..344cce236b1 100644 --- a/admin/app/components/solidus_admin/orders/index/component.html.erb +++ b/admin/app/components/solidus_admin/orders/index/component.html.erb @@ -8,7 +8,7 @@ <%= render component("ui/button").new( tag: :a, text: t('.create_order'), - href: solidus_admin.new_order_path, + href: spree.new_admin_order_path, icon: "add-line", ) %> diff --git a/admin/app/components/solidus_admin/orders/new/component.html.erb b/admin/app/components/solidus_admin/orders/show/component.html.erb similarity index 64% rename from admin/app/components/solidus_admin/orders/new/component.html.erb rename to admin/app/components/solidus_admin/orders/show/component.html.erb index 350c9362d24..fc5ecff6a5e 100644 --- a/admin/app/components/solidus_admin/orders/new/component.html.erb +++ b/admin/app/components/solidus_admin/orders/show/component.html.erb @@ -1,12 +1,12 @@ <%= page do %> <%= page_header do %> - <%= page_header_back solidus_admin.orders_path %> - - <%= page_header_title t(".create_order") %> - + <%= page_header_back(solidus_admin.orders_path) %> + <%= page_header_title(t('.title', number: @order.number)) %> <%= page_header_actions do %> <%= render component("ui/button").new(tag: :button, scheme: :secondary, text: t(".discard"), form: form_id) %> <%= 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/components/solidus_admin/orders/new/component.js b/admin/app/components/solidus_admin/orders/show/component.js similarity index 100% rename from admin/app/components/solidus_admin/orders/new/component.js rename to admin/app/components/solidus_admin/orders/show/component.js diff --git a/admin/app/components/solidus_admin/orders/new/component.rb b/admin/app/components/solidus_admin/orders/show/component.rb similarity index 73% rename from admin/app/components/solidus_admin/orders/new/component.rb rename to admin/app/components/solidus_admin/orders/show/component.rb index 7d98d44eb30..b0187d09da8 100644 --- a/admin/app/components/solidus_admin/orders/new/component.rb +++ b/admin/app/components/solidus_admin/orders/show/component.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class SolidusAdmin::Orders::New::Component < SolidusAdmin::BaseComponent +class SolidusAdmin::Orders::Show::Component < SolidusAdmin::BaseComponent include SolidusAdmin::Layout::PageHelpers def initialize(order:) diff --git a/admin/app/components/solidus_admin/orders/new/component.yml b/admin/app/components/solidus_admin/orders/show/component.yml similarity index 83% rename from admin/app/components/solidus_admin/orders/new/component.yml rename to admin/app/components/solidus_admin/orders/show/component.yml index d78429ce878..785996266e6 100644 --- a/admin/app/components/solidus_admin/orders/new/component.yml +++ b/admin/app/components/solidus_admin/orders/show/component.yml @@ -1,6 +1,6 @@ # Add your component translations here. # Use the translation in the example in your template with `t(".hello")`. en: - create_order: Create Order save: Save discard: Discard + title: "Order %{number}" diff --git a/admin/app/components/solidus_admin/ui/icon/component.rb b/admin/app/components/solidus_admin/ui/icon/component.rb index c46963c1de8..ab3ccc3d9d8 100644 --- a/admin/app/components/solidus_admin/ui/icon/component.rb +++ b/admin/app/components/solidus_admin/ui/icon/component.rb @@ -16,6 +16,10 @@ def initialize(name:, **attrs) # Hide the icon from screen readers by default. @attrs['aria-hidden'] = true unless @attrs.key?('aria-hidden') + + # Default icons without style to 16x16, mostly useful in test snapshots. + @attrs['width'] = '16' + @attrs['height'] = '16' end def call diff --git a/admin/app/components/solidus_admin/ui/toast/component.html.erb b/admin/app/components/solidus_admin/ui/toast/component.html.erb index 5971fa2a948..559fdc0dd5c 100644 --- a/admin/app/components/solidus_admin/ui/toast/component.html.erb +++ b/admin/app/components/solidus_admin/ui/toast/component.html.erb @@ -1,7 +1,7 @@
- <%= render current_component.new(order: order) %> -
diff --git a/admin/spec/components/solidus_admin/orders/new/component_spec.rb b/admin/spec/components/solidus_admin/orders/new/component_spec.rb deleted file mode 100644 index 6f61edbc87e..00000000000 --- a/admin/spec/components/solidus_admin/orders/new/component_spec.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe SolidusAdmin::Orders::New::Component, type: :component do - it "renders the overview preview" do - render_preview(:overview) - 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