From 7c3c9394925f8a24987810285df9cb5ca671898e Mon Sep 17 00:00:00 2001 From: Elia Schito Date: Fri, 27 Oct 2023 17:25:18 +0200 Subject: [PATCH] Extract a `ui/search_panel` component from `orders/cart` --- .../orders/cart/component.html.erb | 175 +++++++----------- .../solidus_admin/orders/cart/component.js | 124 ++----------- .../solidus_admin/orders/cart/component.yml | 3 - .../orders/cart/result/component.html.erb | 54 +++--- .../orders/cart/result/component.js | 7 - .../ui/search_panel/component.html.erb | 58 ++++++ .../ui/search_panel/component.js | 116 ++++++++++++ .../ui/search_panel/component.rb | 9 + .../ui/search_panel/component.yml | 4 + .../ui/search_panel/result/component.rb | 12 ++ .../ui/search_panel/component_preview.rb | 10 + .../component_preview/overview.html.erb | 24 +++ .../ui/search_panel/component_spec.rb | 9 + admin/spec/features/order_spec.rb | 2 +- 14 files changed, 346 insertions(+), 261 deletions(-) delete mode 100644 admin/app/components/solidus_admin/orders/cart/result/component.js create mode 100644 admin/app/components/solidus_admin/ui/search_panel/component.html.erb create mode 100644 admin/app/components/solidus_admin/ui/search_panel/component.js create mode 100644 admin/app/components/solidus_admin/ui/search_panel/component.rb create mode 100644 admin/app/components/solidus_admin/ui/search_panel/component.yml create mode 100644 admin/app/components/solidus_admin/ui/search_panel/result/component.rb create mode 100644 admin/spec/components/previews/solidus_admin/ui/search_panel/component_preview.rb create mode 100644 admin/spec/components/previews/solidus_admin/ui/search_panel/component_preview/overview.html.erb create mode 100644 admin/spec/components/solidus_admin/ui/search_panel/component_spec.rb diff --git a/admin/app/components/solidus_admin/orders/cart/component.html.erb b/admin/app/components/solidus_admin/orders/cart/component.html.erb index d58616fb8d4..d77b2a63a2f 100644 --- a/admin/app/components/solidus_admin/orders/cart/component.html.erb +++ b/admin/app/components/solidus_admin/orders/cart/component.html.erb @@ -3,118 +3,77 @@ data-controller="<%= stimulus_id %>" data-<%= stimulus_id %>-products-url-value="<%= solidus_admin.variants_for_order_path(@order) %>" data-action=" - keydown-><%= stimulus_id %>#selectResult + <%= component('ui/search_panel').stimulus_id %>:search-><%= stimulus_id %>#search + <%= component('ui/search_panel').stimulus_id %>:submit-><%= 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(title: t('.title')) 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 %> -
- - + <%= render component('ui/search_panel').new( + title: t('.title'), + search_placeholder: t('.search_placeholder'), + id: :order_cart, + ) do |panel| %> +
> + + + + + + + + + + <% @order.line_items.each do |line_item| %> - - - - - - - - <% @order.line_items.each do |line_item| %> - - - - - - - <% end %> - -
ProductQuantityPriceActions
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("- ") %> -
+
+
+ <% 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 %> -
-
- + + + + <%= 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 %> + + <% end %> diff --git a/admin/app/components/solidus_admin/orders/cart/component.js b/admin/app/components/solidus_admin/orders/cart/component.js index 0d1fd7441d8..cce25e6b6f2 100644 --- a/admin/app/components/solidus_admin/orders/cart/component.js +++ b/admin/app/components/solidus_admin/orders/cart/component.js @@ -1,92 +1,19 @@ import { Controller } from "@hotwired/stimulus" -import { useClickOutside, useDebounce } from "stimulus-use" - -const QUERY_KEY = "q[name_or_variants_including_master_sku_cont]" +import { useDebounce } from "stimulus-use" 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] - } + static values = { productsUrl: String } + static debounces = [{ name: "submitLineItems", wait: 500 }] 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() + async search({ detail: { query, controller } }) { + controller.resultsValue = await ( + await fetch(`${this.productsUrlValue}?q[name_or_variants_including_master_sku_cont]=${query}`) + ).text() } updateLineItem(event) { @@ -94,42 +21,17 @@ export default class extends Controller { this.lineItemsToBeSubmitted.push(event.currentTarget) } - this.requestSubmitForLineItems() + this.submitLineItems() } // This is a workaround to permit using debounce when needing to pass a parameter - requestSubmitForLineItems() { - this.lineItemsToBeSubmitted.forEach((lineItem) => { - lineItem.form.requestSubmit() - }) + submitLineItems() { + 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 - } + selectResult(event) { + const form = event.detail.resultTarget.querySelector("form") + form.submit() } } diff --git a/admin/app/components/solidus_admin/orders/cart/component.yml b/admin/app/components/solidus_admin/orders/cart/component.yml index fe281ed1dc2..7bf7a242a5c 100644 --- a/admin/app/components/solidus_admin/orders/cart/component.yml +++ b/admin/app/components/solidus_admin/orders/cart/component.yml @@ -3,6 +3,3 @@ en: title: "Cart" 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 index 8fbdf383ac6..7ef48986da5 100644 --- a/admin/app/components/solidus_admin/orders/cart/result/component.html.erb +++ b/admin/app/components/solidus_admin/orders/cart/result/component.html.erb @@ -1,34 +1,26 @@ -<%= 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('ui/search_panel/result').new do %> + <%= form_for(@order.line_items.build(variant: @variant), url: solidus_admin.order_line_items_path(@order), method: :post, html: { + "data-controller": "readonly-when-submitting", + class: "flex items-center", + }) 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 %> -
+
+ <%= render component("products/stock").from_variant(@variant) %> + <%= @variant.display_price.to_html %> +
+ <% end %> <% 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 deleted file mode 100644 index 6778c98c6da..00000000000 --- a/admin/app/components/solidus_admin/orders/cart/result/component.js +++ /dev/null @@ -1,7 +0,0 @@ -import { Controller } from '@hotwired/stimulus' - -export default class extends Controller { - submit(event) { - event.currentTarget.requestSubmit() - } -} diff --git a/admin/app/components/solidus_admin/ui/search_panel/component.html.erb b/admin/app/components/solidus_admin/ui/search_panel/component.html.erb new file mode 100644 index 00000000000..bb4403ca1ab --- /dev/null +++ b/admin/app/components/solidus_admin/ui/search_panel/component.html.erb @@ -0,0 +1,58 @@ +
-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(**@panel_args) do |panel| %> +
+
+
+ <%= render component("ui/forms/search_field").new( + id: "#{stimulus_id}--search-field--#{@id}", + placeholder: @search_placeholder, + "data-action": " + #{stimulus_id}#search + #{stimulus_id}#showResults + ", + "data-#{stimulus_id}-target": "searchField", + ) %> +
+ +
+ +
-target="results" + > +
+
+
+ +
+ <%= content %> +
+ <% end %> +
diff --git a/admin/app/components/solidus_admin/ui/search_panel/component.js b/admin/app/components/solidus_admin/ui/search_panel/component.js new file mode 100644 index 00000000000..d37938d1d7a --- /dev/null +++ b/admin/app/components/solidus_admin/ui/search_panel/component.js @@ -0,0 +1,116 @@ +import { Controller } from "@hotwired/stimulus" +import { useClickOutside, useDebounce } from "stimulus-use" + +export default class extends Controller { + static targets = ["result", "results", "searchField"] + static values = { + results: String, + loadingText: String, + initialText: String, + emptyText: String, + } + static debounces = ["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 + + if (this.query) { + this.showResults() + this.search() + } + } + + selectResult(event) { + event.preventDefault() + this.dispatch("submit", { detail: { resultTarget: this.selectedResult } }) + } + + clickedResult(event) { + this.selectedIndex = this.resultTargets.indexOf(event.currentTarget) + this.render() + this.selectResult(event) + } + + selectPrev(event) { + event.preventDefault() + this.selectedIndex -= 1 + this.render() + } + + selectNext(event) { + event.preventDefault() + this.selectedIndex += 1 + this.render() + } + + clickOutside() { + this.openResults = false + this.render() + } + + async search() { + const query = this.query + + if (query) { + this.resultsValue = this.loadingTextValue + this.render() + this.dispatch("search", { detail: { query, controller: this } }) + } else { + this.resultsValue = this.initialTextValue + this.render() + } + } + + resultsValueChanged() { + this.selectedIndex = 0 + this.render() + } + + showResults() { + this.openResults = true + this.render() + } + + 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/ui/search_panel/component.rb b/admin/app/components/solidus_admin/ui/search_panel/component.rb new file mode 100644 index 00000000000..b8f32ac5382 --- /dev/null +++ b/admin/app/components/solidus_admin/ui/search_panel/component.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class SolidusAdmin::UI::SearchPanel::Component < SolidusAdmin::BaseComponent + def initialize(search_placeholder: nil, id: nil, **panel_args) + @search_placeholder = search_placeholder + @panel_args = panel_args + @id = id + end +end diff --git a/admin/app/components/solidus_admin/ui/search_panel/component.yml b/admin/app/components/solidus_admin/ui/search_panel/component.yml new file mode 100644 index 00000000000..05ebdc5b93c --- /dev/null +++ b/admin/app/components/solidus_admin/ui/search_panel/component.yml @@ -0,0 +1,4 @@ +en: + loading: "Loading..." + initial: "Type to search" + empty: "No results" diff --git a/admin/app/components/solidus_admin/ui/search_panel/result/component.rb b/admin/app/components/solidus_admin/ui/search_panel/result/component.rb new file mode 100644 index 00000000000..520bbbce5ed --- /dev/null +++ b/admin/app/components/solidus_admin/ui/search_panel/result/component.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class SolidusAdmin::UI::SearchPanel::Result::Component < SolidusAdmin::BaseComponent + def call + tag.div( + content, + class: "rounded p-2 hover:bg-gray-25 aria-selected:bg-gray-25 cursor-pointer", + "data-#{component('ui/search_panel').stimulus_id}-target": "result", + "data-action": "click->#{component('ui/search_panel').stimulus_id}#clickedResult", + ) + end +end diff --git a/admin/spec/components/previews/solidus_admin/ui/search_panel/component_preview.rb b/admin/spec/components/previews/solidus_admin/ui/search_panel/component_preview.rb new file mode 100644 index 00000000000..cc3ab94a5dc --- /dev/null +++ b/admin/spec/components/previews/solidus_admin/ui/search_panel/component_preview.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# @component "ui/search_panel" +class SolidusAdmin::UI::SearchPanel::ComponentPreview < ViewComponent::Preview + include SolidusAdmin::Preview + + def overview + render_with_template + end +end diff --git a/admin/spec/components/previews/solidus_admin/ui/search_panel/component_preview/overview.html.erb b/admin/spec/components/previews/solidus_admin/ui/search_panel/component_preview/overview.html.erb new file mode 100644 index 00000000000..6699a39eaef --- /dev/null +++ b/admin/spec/components/previews/solidus_admin/ui/search_panel/component_preview/overview.html.erb @@ -0,0 +1,24 @@ +
+
+ Default +
+ + + + <%= render current_component.new %> +
diff --git a/admin/spec/components/solidus_admin/ui/search_panel/component_spec.rb b/admin/spec/components/solidus_admin/ui/search_panel/component_spec.rb new file mode 100644 index 00000000000..15209522472 --- /dev/null +++ b/admin/spec/components/solidus_admin/ui/search_panel/component_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe SolidusAdmin::UI::SearchPanel::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 index 95dff2483ba..d2a41852e29 100644 --- a/admin/spec/features/order_spec.rb +++ b/admin/spec/features/order_spec.rb @@ -15,7 +15,7 @@ expect(page).to have_content("Order R123456789") - search_field = find("[data-#{SolidusAdmin::Orders::Cart::Component.stimulus_id}-target='searchField']") + search_field = find("[data-#{SolidusAdmin::UI::SearchPanel::Component.stimulus_id}-target='searchField']") search_field.set "another" expect(page).not_to have_content("Just a product")