diff --git a/admin/app/components/solidus_admin/users/addresses/component.html.erb b/admin/app/components/solidus_admin/users/addresses/component.html.erb index b2d41f37e3c..267295d8ca5 100644 --- a/admin/app/components/solidus_admin/users/addresses/component.html.erb +++ b/admin/app/components/solidus_admin/users/addresses/component.html.erb @@ -40,17 +40,7 @@ <% end %> <%= page_with_sidebar_aside do %> - <%= render component("ui/panel").new(title: t("spree.lifetime_stats")) do %> - <%= render component("ui/details_list").new( - items: [ - { label: t("spree.total_sales"), value: @user.display_lifetime_value.to_html }, - { label: t("spree.order_count"), value: @user.order_count.to_i }, - { label: t("spree.average_order_value"), value: @user.display_average_order_value.to_html }, - { label: t("spree.member_since"), value: @user.created_at.to_date }, - { label: t(".last_active"), value: last_login(@user) }, - ] - ) %> - <% end %> + <%= render component("users/stats").new(user: @user) %> <% end %> <% end %> <% end %> diff --git a/admin/app/components/solidus_admin/users/addresses/component.rb b/admin/app/components/solidus_admin/users/addresses/component.rb index 6116312b866..0432aefba80 100644 --- a/admin/app/components/solidus_admin/users/addresses/component.rb +++ b/admin/app/components/solidus_admin/users/addresses/component.rb @@ -43,17 +43,6 @@ def tabs ] end - def last_login(user) - return t('.last_login.never') if user.try(:last_sign_in_at).blank? - - t( - '.last_login.login_time_ago', - # @note The second `.try` is here for the specs and for setups that use a - # custom User class which may not have this attribute. - last_login_time: time_ago_in_words(user.try(:last_sign_in_at)) - ).capitalize - end - def bill_address(address, type) if address.present? && type == "bill" address diff --git a/admin/app/components/solidus_admin/users/addresses/component.yml b/admin/app/components/solidus_admin/users/addresses/component.yml index 6872c1d5cd5..dfeaebe49a2 100644 --- a/admin/app/components/solidus_admin/users/addresses/component.yml +++ b/admin/app/components/solidus_admin/users/addresses/component.yml @@ -6,10 +6,6 @@ en: items: Items store_credit: Store Credit last_active: Last Active - last_login: - login_time_ago: "%{last_login_time} ago" - never: Never - invitation_sent: Invitation sent create_order_for_user: Create order for this user update: Update cancel: Cancel diff --git a/admin/app/components/solidus_admin/users/edit/component.html.erb b/admin/app/components/solidus_admin/users/edit/component.html.erb index b9b8cbe4488..1f7036c314a 100644 --- a/admin/app/components/solidus_admin/users/edit/component.html.erb +++ b/admin/app/components/solidus_admin/users/edit/component.html.erb @@ -46,17 +46,7 @@ <% end %> <%= page_with_sidebar_aside do %> - <%= render component("ui/panel").new(title: t("spree.lifetime_stats")) do %> - <%= render component("ui/details_list").new( - items: [ - { label: t("spree.total_sales"), value: @user.display_lifetime_value.to_html }, - { label: t("spree.order_count"), value: @user.order_count.to_i }, - { label: t("spree.average_order_value"), value: @user.display_average_order_value.to_html }, - { label: t("spree.member_since"), value: @user.created_at.to_date }, - { label: t(".last_active"), value: last_login(@user) }, - ] - ) %> - <% end %> + <%= render component("users/stats").new(user: @user) %> <% end %> <% end %> <% end %> diff --git a/admin/app/components/solidus_admin/users/edit/component.rb b/admin/app/components/solidus_admin/users/edit/component.rb index 19ff9adbc8e..738764c5f4a 100644 --- a/admin/app/components/solidus_admin/users/edit/component.rb +++ b/admin/app/components/solidus_admin/users/edit/component.rb @@ -43,16 +43,6 @@ def tabs ] end - def last_login(user) - return t('.last_login.never') if user.try(:last_sign_in_at).blank? - - t( - '.last_login.login_time_ago', - # @note The second `.try` is only here for the specs to work. - last_login_time: time_ago_in_words(user.try(:last_sign_in_at)) - ).capitalize - end - def role_options Spree::Role.all.map do |role| { label: role.name, id: role.id } diff --git a/admin/app/components/solidus_admin/users/edit/component.yml b/admin/app/components/solidus_admin/users/edit/component.yml index 01b3cf230ac..4aa3d1056f1 100644 --- a/admin/app/components/solidus_admin/users/edit/component.yml +++ b/admin/app/components/solidus_admin/users/edit/component.yml @@ -6,10 +6,6 @@ en: items: Items store_credit: Store Credit last_active: Last Active - last_login: - login_time_ago: "%{last_login_time} ago" - never: Never - invitation_sent: Invitation sent create_order_for_user: Create order for this user update: Update cancel: Cancel diff --git a/admin/app/components/solidus_admin/users/index/component.rb b/admin/app/components/solidus_admin/users/index/component.rb index 0b911061d90..2d6db499bc9 100644 --- a/admin/app/components/solidus_admin/users/index/component.rb +++ b/admin/app/components/solidus_admin/users/index/component.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class SolidusAdmin::Users::Index::Component < SolidusAdmin::UsersAndRoles::Component + include SolidusAdmin::LastLoginHelper + def model_class Spree.user_class end @@ -93,20 +95,4 @@ def columns }, ] end - - private - - # @todo add logic to display "Invitation sent" when the user has not yet - # accepted the invitation and filled out account details. To be implemented - # in conjunction with the invitation logic. - def last_login(user) - return t('.last_login.never') if user.try(:last_sign_in_at).blank? - - t( - '.last_login.login_time_ago', - # @note The second `.try` is here for the specs and for setups that use a - # custom User class which may not have this attribute. - last_login_time: time_ago_in_words(user.try(:last_sign_in_at)) - ).capitalize - end end diff --git a/admin/app/components/solidus_admin/users/index/component.yml b/admin/app/components/solidus_admin/users/index/component.yml index 789fbb6a3cf..ba63d2d41f6 100644 --- a/admin/app/components/solidus_admin/users/index/component.yml +++ b/admin/app/components/solidus_admin/users/index/component.yml @@ -13,7 +13,3 @@ en: status: active: Active inactive: Inactive - last_login: - login_time_ago: "%{last_login_time} ago" - never: Never - invitation_sent: Invitation sent 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 00000000000..82dd555cf86 --- /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/stats").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 00000000000..f86628d6c0d --- /dev/null +++ b/admin/app/components/solidus_admin/users/items/component.rb @@ -0,0 +1,170 @@ +# 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 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? + + # The `.html_safe` is required for the description to display as desired. + # rubocop:disable Rails/OutputSafety + safe_join([content_tag(:div, content.join("
").html_safe, class: "text-sm")]) + # rubocop:enable Rails/OutputSafety + 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 00000000000..2659e722c06 --- /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/components/solidus_admin/users/orders/component.html.erb b/admin/app/components/solidus_admin/users/orders/component.html.erb index a386d089b84..a157f46f8b5 100644 --- a/admin/app/components/solidus_admin/users/orders/component.html.erb +++ b/admin/app/components/solidus_admin/users/orders/component.html.erb @@ -36,17 +36,7 @@ <% end %> <%= page_with_sidebar_aside do %> - <%= render component("ui/panel").new(title: t("spree.lifetime_stats")) do %> - <%= render component("ui/details_list").new( - items: [ - { label: t("spree.total_sales"), value: @user.display_lifetime_value.to_html }, - { label: t("spree.order_count"), value: @user.order_count.to_i }, - { label: t("spree.average_order_value"), value: @user.display_average_order_value.to_html }, - { label: t("spree.member_since"), value: @user.created_at.to_date }, - { label: t(".last_active"), value: last_login(@user) }, - ] - ) %> - <% end %> + <%= render component("users/stats").new(user: @user) %> <% end %> <% end %> <% end %> diff --git a/admin/app/components/solidus_admin/users/orders/component.rb b/admin/app/components/solidus_admin/users/orders/component.rb index ee57928111b..d2b80caf14d 100644 --- a/admin/app/components/solidus_admin/users/orders/component.rb +++ b/admin/app/components/solidus_admin/users/orders/component.rb @@ -8,10 +8,6 @@ def initialize(user:, orders:) @orders = orders end - def form_id - @form_id ||= "#{stimulus_id}--form-#{@user.id}" - end - def tabs [ { @@ -42,16 +38,6 @@ def tabs ] end - def last_login(user) - return t('.last_login.never') if user.try(:last_sign_in_at).blank? - - t( - '.last_login.login_time_ago', - # @note The second `.try` is only here for the specs to work. - last_login_time: time_ago_in_words(user.try(:last_sign_in_at)) - ).capitalize - end - def model_class Spree::Order end diff --git a/admin/app/components/solidus_admin/users/orders/component.yml b/admin/app/components/solidus_admin/users/orders/component.yml index 6c4b6f1368a..98c4724fe23 100644 --- a/admin/app/components/solidus_admin/users/orders/component.yml +++ b/admin/app/components/solidus_admin/users/orders/component.yml @@ -6,10 +6,6 @@ en: items: Items store_credit: Store Credit last_active: Last Active - last_login: - login_time_ago: "%{last_login_time} ago" - never: Never - invitation_sent: Invitation sent create_order_for_user: Create order for this user no_orders_found: No Orders found. create_one: Create One diff --git a/admin/app/components/solidus_admin/users/stats/component.html.erb b/admin/app/components/solidus_admin/users/stats/component.html.erb new file mode 100644 index 00000000000..888473b400b --- /dev/null +++ b/admin/app/components/solidus_admin/users/stats/component.html.erb @@ -0,0 +1,11 @@ +<%= render component("ui/panel").new(title: t("spree.lifetime_stats")) do %> + <%= render component("ui/details_list").new( + items: [ + { label: t("spree.total_sales"), value: @user.display_lifetime_value.to_html }, + { label: t("spree.order_count"), value: @user.order_count.to_i }, + { label: t("spree.average_order_value"), value: @user.display_average_order_value.to_html }, + { label: t("spree.member_since"), value: @user.created_at.to_date }, + { label: t(".last_active"), value: last_login(@user) }, + ] + ) %> +<% end %> diff --git a/admin/app/components/solidus_admin/users/stats/component.rb b/admin/app/components/solidus_admin/users/stats/component.rb new file mode 100644 index 00000000000..e93aef7b03e --- /dev/null +++ b/admin/app/components/solidus_admin/users/stats/component.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class SolidusAdmin::Users::Stats::Component < SolidusAdmin::BaseComponent + include SolidusAdmin::LastLoginHelper + + def initialize(user:) + @user = user + end +end diff --git a/admin/app/components/solidus_admin/users/stats/component.yml b/admin/app/components/solidus_admin/users/stats/component.yml new file mode 100644 index 00000000000..f01f52d773e --- /dev/null +++ b/admin/app/components/solidus_admin/users/stats/component.yml @@ -0,0 +1,2 @@ +en: + last_active: Last Active diff --git a/admin/app/controllers/solidus_admin/users_controller.rb b/admin/app/controllers/solidus_admin/users_controller.rb index 8b1a7858598..7e7d181a1bd 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/app/helpers/solidus_admin/last_login_helper.rb b/admin/app/helpers/solidus_admin/last_login_helper.rb new file mode 100644 index 00000000000..95f49b00c00 --- /dev/null +++ b/admin/app/helpers/solidus_admin/last_login_helper.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module SolidusAdmin + module LastLoginHelper + def last_login(user) + return t('solidus_admin.users.last_login.never') if user.try(:last_sign_in_at).blank? + + t( + 'solidus_admin.users.last_login.login_time_ago', + # @note The second `.try` is here for the specs and for setups that use a + # custom User class which may not have this attribute. + last_login_time: time_ago_in_words(user.try(:last_sign_in_at)) + ).capitalize + end + end +end diff --git a/admin/config/locales/users.en.yml b/admin/config/locales/users.en.yml index 5fda686929c..8a08a99a8aa 100644 --- a/admin/config/locales/users.en.yml +++ b/admin/config/locales/users.en.yml @@ -10,3 +10,7 @@ en: ship: success: "Shipping Address has been successfully updated." error: "Address could not be updated." + last_login: + never: "Never" + login_time_ago: "%{last_login_time} ago" + invitation_sent: "Invitation sent" diff --git a/admin/config/routes.rb b/admin/config/routes.rb index d33b23d60cc..862ee70212c 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 0a4f450c984..b6ac4e097ab 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/helpers/solidus_admin/last_login_helper_spec.rb b/admin/spec/helpers/solidus_admin/last_login_helper_spec.rb new file mode 100644 index 00000000000..9b2099f6973 --- /dev/null +++ b/admin/spec/helpers/solidus_admin/last_login_helper_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe SolidusAdmin::LastLoginHelper, type: :helper do + describe "#last_login" do + let(:user) { double("User") } + + context "when user has never logged in" do + it "returns 'Never'" do + allow(user).to receive(:last_sign_in_at).and_return(nil) + + expect(helper.last_login(user)).to eq("Never") + end + end + + context "when user has logged in before" do + it "returns the time ago since the last login, capitalized" do + last_sign_in_time = 2.days.ago + allow(user).to receive(:last_sign_in_at).and_return(last_sign_in_time) + + expect(helper) + .to receive(:time_ago_in_words) + .with(last_sign_in_time) + .and_return("2 days") + + expect(helper.last_login(user)).to eq("2 days ago") + 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 882342189b7..c2ca7c88e2b 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