Skip to content

Commit

Permalink
Add an orders/cart component and supporting controllers
Browse files Browse the repository at this point in the history
  • Loading branch information
elia committed Oct 23, 2023
1 parent efba3a5 commit 2d07ba2
Show file tree
Hide file tree
Showing 13 changed files with 425 additions and 0 deletions.
115 changes: 115 additions & 0 deletions admin/app/components/solidus_admin/orders/cart/component.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<div
class="w-full relative overflow-visible"
data-controller="<%= stimulus_id %>"
data-<%= stimulus_id %>-products-url-value="<%= solidus_admin.variants_for_order_path(@order) %>"
data-action="
keydown-><%= stimulus_id %>#selectResult
"
>
<%= render component('ui/panel').new do |panel| %>
<div>
<div class="peer">
<%= 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",
) %>
</div>

<%# results popover %>
<details class="px-6 relative overflow-visible">
<summary class="hidden"></summary>
<div
class="
absolute
left-0
top-2
bg-white
z-30
w-full
rounded-lg
shadow
border
border-gray-100
p-2
flex-col
gap-1
max-h-screen
overflow-y-auto
"
data-<%= stimulus_id %>-target="results"
>
</div>
</details>

</div>

<%# line items table %>
<div class="rounded-b-lg -mx-6 -mb-6 overflow-hidden">
<table class="table-auto w-full">
<thead>
<tr class="border-gray-100 border-t">
<th class="text-left body-small-bold text-gray-800 bg-gray-15 px-6 py-3 leading-none">Product</th>
<th class="text-left body-small-bold text-gray-800 bg-gray-15 px-6 py-3 leading-none w-16">Quantity</th>
<th class="text-left body-small-bold text-gray-800 bg-gray-15 px-6 py-3 leading-none w-16">Price</th>
<th class="text-left body-small-bold text-gray-800 bg-gray-15 px-6 py-3 leading-none w-16"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
<% @order.line_items.each do |line_item| %>
<tr class="border-gray-100 border-t">
<td class="px-6 py-4">
<div class="flex gap-2 grow">
<% variant = line_item.variant %>
<%= render component("ui/thumbnail").new(
src: (variant.images.first || variant.product.gallery.images.first)&.url(:small),
alt: variant.name
) %>
<div class="flex-col">
<div class="leading-5 text-black body-small-bold"><%= variant.name %></div>
<div class="leading-5 text-gray-500 body-small">
SKU: <%= variant.sku %>
<%= variant.options_text.presence&.prepend("- ") %>
</div>
</div>
</div>
</td>
<td class="px-6 py-4">
<%= 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 %>
</td>
<td class="px-6 py-4">
<span class="text-gray-500 body-small"><%= line_item.single_money.to_html %></span>
</td>
<td class="px-6 py-4 text-right">
<%= 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 %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>

<% end %>
</div>
129 changes: 129 additions & 0 deletions admin/app/components/solidus_admin/orders/cart/component.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { Controller } from "@hotwired/stimulus"
import { useClickOutside, useDebounce } from "stimulus-use"

const LOADING = "Loading..."
const INITIAL = "Type to search"
const EMPTY = "No results"
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 }
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 = LOADING
this.render()

this.resultsValue = (await (await fetch(`${this.productsUrlValue}?${QUERY_KEY}=${query}`)).text()) || EMPTY
this.render()
} else {
this.resultsValue = INITIAL
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
}
}
}
7 changes: 7 additions & 0 deletions admin/app/components/solidus_admin/orders/cart/component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

class SolidusAdmin::Orders::Cart::Component < SolidusAdmin::BaseComponent
def initialize(order:)
@order = order
end
end
4 changes: 4 additions & 0 deletions admin/app/components/solidus_admin/orders/cart/component.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# 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"
Original file line number Diff line number Diff line change
@@ -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) %>
<div class="flex gap-2 grow">
<%= render component("ui/thumbnail").new(
src: @image&.url(:small),
alt: @variant.name
) %>
<div class="flex-col">
<div class="leading-5 text-black body-small-bold"><%= @variant.name %></div>
<div class="leading-5 text-gray-500 body-small">
SKU:
<%= @variant.sku %>
<%= @variant.options_text.presence&.prepend("- ") %>
</div>
</div>
</div>
<div class="flex gap-2 items-center">
<span class="text-gray-500 body-small"><%= render component("products/stock").from_variant(@variant) %></span>
<span class="text-black body-small"><%= @variant.display_price.to_html %></span>
</div>
<% end %>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Controller } from '@hotwired/stimulus'

export default class extends Controller {
submit(event) {
debugger
event.currentTarget.requestSubmit()
}
}
11 changes: 11 additions & 0 deletions admin/app/components/solidus_admin/orders/cart/result/component.rb
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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 %>
45 changes: 45 additions & 0 deletions admin/app/controllers/solidus_admin/line_items_controller.rb
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 2d07ba2

Please sign in to comment.