diff --git a/admin/app/components/solidus_admin/users/items/component.html.erb b/admin/app/components/solidus_admin/users/items/component.html.erb new file mode 100644 index 0000000000..a80ee62527 --- /dev/null +++ b/admin/app/components/solidus_admin/users/items/component.html.erb @@ -0,0 +1,41 @@ +<%= page do %> + <%= page_header do %> + <%= page_header_back(solidus_admin.users_path) %> + <%= page_header_title(t(".title", email: @user.email)) %> + + <%= page_header_actions do %> + <%= render component("ui/button").new(tag: :a, text: t(".create_order_for_user"), href: spree.new_admin_order_path(user_id: @user.id)) %> + <% end %> + <% end %> + + <%= page_header do %> + <% tabs.each do |tab| %> + <%= render(component("ui/button").new(tag: :a, scheme: :ghost, text: tab[:text], 'aria-current': tab[:current], href: tab[:href])) %> + <% end %> + <% end %> + + <%= page_with_sidebar do %> + <%= page_with_sidebar_main do %> + <%= render component('ui/panel').new(title: t(".items_purchased")) do %> + <% if @items.present? %> + <%= render component('ui/table').new( + id: stimulus_id, + data: { + class: model_class, + rows: rows, + columns: columns, + url: -> { row_url(_1.order) }, + }, + )%> + <% else %> + <%= t(".no_orders_found") %> + <%= render component("ui/button").new(tag: :a, text: t(".create_one"), href: spree.new_admin_order_path(user_id: @user.id)) %> + <% end %> + <% end %> + <% end %> + + <%= page_with_sidebar_aside do %> + <%= render component("users/last_login").new(user: @user) %> + <% end %> + <% end %> +<% end %> diff --git a/admin/app/components/solidus_admin/users/items/component.rb b/admin/app/components/solidus_admin/users/items/component.rb new file mode 100644 index 0000000000..2a9e9aefa8 --- /dev/null +++ b/admin/app/components/solidus_admin/users/items/component.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +class SolidusAdmin::Users::Items::Component < SolidusAdmin::BaseComponent + include SolidusAdmin::Layout::PageHelpers + + def initialize(user:, items:) + @user = user + @items = items + end + + def form_id + @form_id ||= "#{stimulus_id}--form-#{@user.id}" + end + + def tabs + [ + { + text: t('.account'), + href: solidus_admin.user_path(@user), + current: false, + }, + { + text: t('.addresses'), + href: solidus_admin.addresses_user_path(@user), + current: false, + }, + { + text: t('.order_history'), + href: solidus_admin.orders_user_path(@user), + current: false, + }, + { + text: t('.items'), + href: solidus_admin.items_user_path(@user), + current: true, + }, + { + text: t('.store_credit'), + href: spree.admin_user_store_credits_path(@user), + current: false, + }, + ] + end + + def model_class + Spree::LineItem + end + + def row_url(order) + spree.edit_admin_order_path(order) + end + + def rows + @items + end + + def columns + [ + date_column, + image_column, + description_column, + price_column, + quantity_column, + total_column, + state_column, + number_column, + ] + end + + def date_column + { + col: { class: "w-[8%]" }, + header: :date, + data: ->(item) do + content_tag :div, l(item.order.created_at, format: :short), class: "text-sm" + end + } + end + + def image_column + { + col: { class: "w-[8%]" }, + header: tag.span('aria-label': Spree::Image.model_name.human, role: 'text'), + data: ->(item) do + image = (item.variant.gallery.images.first || item.variant.product.gallery.images.first) or return + + render( + component('ui/thumbnail').new( + src: image.url(:small), + alt: item.product.name + ) + ) + end + } + end + + def description_column + { + col: { class: "w-[24%]" }, + header: t(".description_column_header"), + data: ->(item) { item_name_with_variant_and_sku(item) } + } + end + + def price_column + { + col: { class: "w-[10%]" }, + header: :price, + data: ->(item) do + content_tag :div, item.single_money.to_html + end + } + end + + def quantity_column + { + col: { class: "w-[7%]" }, + header: :qty, + data: ->(item) do + content_tag :div, item.quantity + end + } + end + + def total_column + { + col: { class: "w-[10%]" }, + header: t(".total_column_header"), + data: ->(item) do + content_tag :div, item.money.to_html + end + } + end + + def state_column + { + col: { class: "w-[15%]" }, + header: :state, + data: ->(item) do + color = { + 'complete' => :green, + 'returned' => :red, + 'canceled' => :blue, + 'cart' => :graphite_light, + }[item.order.state] || :yellow + component('ui/badge').new(name: item.order.state.humanize, color: color) + end + } + end + + def number_column + { + col: { class: "w-[18%]" }, + header: t(".number_column_header"), + data: ->(item) do + content_tag :div, item.order.number, class: "font-semibold text-sm" + end + } + end + + private + + def item_name_with_variant_and_sku(item) + content = [] + content << item.product.name + content << "(#{item.variant.options_text})" if item.variant.option_values.any? + content << "#{t('spree.sku')}: #{item.variant.sku}" if item.variant.sku.present? + + safe_join([content_tag(:div, content.join("
").html_safe, class: "text-sm")]) + end +end diff --git a/admin/app/components/solidus_admin/users/items/component.yml b/admin/app/components/solidus_admin/users/items/component.yml new file mode 100644 index 0000000000..2659e722c0 --- /dev/null +++ b/admin/app/components/solidus_admin/users/items/component.yml @@ -0,0 +1,16 @@ +en: + title: "Users / %{email} / Items Purchased" + account: Account + addresses: Addresses + order_history: Order History + items: Items + store_credit: Store Credit + last_active: Last Active + create_order_for_user: Create order for this user + items_purchased: Items Purchased + no_orders_found: No Orders found. + create_one: Create One + back: Back + number_column_header: "Order #" + description_column_header: "Description" + total_column_header: "Total" diff --git a/admin/app/controllers/solidus_admin/users_controller.rb b/admin/app/controllers/solidus_admin/users_controller.rb index 8b1a785859..7e7d181a1b 100644 --- a/admin/app/controllers/solidus_admin/users_controller.rb +++ b/admin/app/controllers/solidus_admin/users_controller.rb @@ -5,7 +5,7 @@ class UsersController < SolidusAdmin::BaseController include SolidusAdmin::ControllerHelpers::Search include Spree::Core::ControllerHelpers::StrongParameters - before_action :set_user, only: [:edit, :addresses, :update_addresses, :orders] + before_action :set_user, only: [:edit, :addresses, :update_addresses, :orders, :items] search_scope(:all, default: true) search_scope(:customers) { _1.left_outer_joins(:role_users).where(role_users: { id: nil }) } @@ -58,6 +58,14 @@ def orders end end + def items + set_items + + respond_to do |format| + format.html { render component('users/items').new(user: @user, items: @items) } + end + end + def edit respond_to do |format| format.html { render component('users/edit').new(user: @user) } @@ -107,6 +115,13 @@ def set_orders @orders = @search.result.page(params[:page]).per(Spree::Config[:admin_products_per_page]) end + def set_items + params[:q] ||= {} + @search = Spree::Order.reverse_chronological.includes(line_items: { variant: [:product, { option_values: :option_type }] }).ransack(params[:q].merge(user_id_eq: @user.id)) + @orders = @search.result.page(params[:page]).per(Spree::Config[:admin_products_per_page]) + @items = @orders&.map(&:line_items)&.flatten + end + def authorization_subject Spree.user_class end diff --git a/admin/config/routes.rb b/admin/config/routes.rb index d33b23d60c..862ee70212 100644 --- a/admin/config/routes.rb +++ b/admin/config/routes.rb @@ -50,6 +50,7 @@ get :addresses put :update_addresses get :orders + get :items end end diff --git a/admin/spec/features/users_spec.rb b/admin/spec/features/users_spec.rb index 0a4f450c98..b6ac4e097a 100644 --- a/admin/spec/features/users_spec.rb +++ b/admin/spec/features/users_spec.rb @@ -206,4 +206,50 @@ end end end + + context "when viewing a user's purchased items" do + context "when a user has no purchased items" do + before do + create(:user, email: "customer@example.com") + visit "/admin/users" + find_row("customer@example.com").click + click_on "Items" + end + + it "shows the purchased items page" do + expect(page).to have_content("Users / customer@example.com / Items Purchased") + expect(page).to have_content("Lifetime Stats") + expect(page).to have_content("Items Purchased") + expect(page).to be_axe_clean + end + + it "shows the appropriate content" do + expect(page).to have_content("No Orders found.") + end + end + + context "when a user has ordered before" do + before do + create(:order_with_line_items, user: create(:user, email: "loyal_customer@example.com")) + visit "/admin/users" + find_row("loyal_customer@example.com").click + click_on "Items" + end + + it "shows the purchased items page" do + expect(page).to have_content("Users / loyal_customer@example.com / Items Purchased") + expect(page).to have_content("Lifetime Stats") + expect(page).to have_content("Items Purchased") + expect(page).to be_axe_clean + end + + it "lists the purchased items" do + expect(page).to have_content(/R\d+/) # Matches on any order number. + expect(page).to have_content("Description") + expect(page).to have_content("Qty") + expect(page).to have_content("State") + expect(page).not_to have_content("No Orders found.") + end + end + end end diff --git a/admin/spec/requests/solidus_admin/users_spec.rb b/admin/spec/requests/solidus_admin/users_spec.rb index 882342189b..c2ca7c88e2 100644 --- a/admin/spec/requests/solidus_admin/users_spec.rb +++ b/admin/spec/requests/solidus_admin/users_spec.rb @@ -69,6 +69,16 @@ end end + describe "GET /items" do + let!(:order) { create(:order_with_line_items, user: user) } + + it "renders the items template and displays the user's purchased items" do + get solidus_admin.items_user_path(user) + expect(response).to have_http_status(:ok) + expect(response.body).to include(order.number) + end + end + describe "DELETE /destroy" do it "deletes the user and redirects to the index page with a 303 See Other status" do # Ensure the user exists prior to deletion