diff --git a/admin/app/components/solidus_admin/users/addresses/component.html.erb b/admin/app/components/solidus_admin/users/addresses/component.html.erb new file mode 100644 index 0000000000..e9a695a90e --- /dev/null +++ b/admin/app/components/solidus_admin/users/addresses/component.html.erb @@ -0,0 +1,56 @@ +<%= 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(".billing_address")) do %> + <%= form_for @user, url: solidus_admin.update_addresses_user_path(@user), method: :put, html: { id: "#{form_id}_billing", autocomplete: "off", class: "bill_address" } do |f| %> + + <%= render component('ui/forms/address').new(address: (@user.bill_address || Spree::Address.build_default), name: "user[bill_address_attributes]") %> +
+ <%= render component("ui/button").new(tag: :button, text: t(".update"), form: "#{form_id}_billing") %> + <%= render component("ui/button").new(tag: :a, text: t(".cancel"), href: solidus_admin.addresses_user_path(@user), scheme: :secondary) %> +
+ <% end %> + <% end %> + + <%= render component('ui/panel').new(title: t(".shipping_address")) do %> + <%= form_for @user, url: solidus_admin.update_addresses_user_path(@user), method: :put, html: { id: "#{form_id}_shipping", autocomplete: "off", class: "ship_address" } do |f| %> + + <%= render component('ui/forms/address').new(address: (@user.ship_address || Spree::Address.build_default), name: "user[ship_address_attributes]") %> +
+ <%= render component("ui/button").new(tag: :button, text: t(".update"), form: "#{form_id}_shipping") %> + <%= render component("ui/button").new(tag: :a, text: t(".cancel"), href: solidus_admin.addresses_user_path(@user), scheme: :secondary) %> +
+ <% end %> + <% end %> + <% 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 %> + <% 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 new file mode 100644 index 0000000000..6f2baf99c7 --- /dev/null +++ b/admin/app/components/solidus_admin/users/addresses/component.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +class SolidusAdmin::Users::Addresses::Component < SolidusAdmin::BaseComponent + include SolidusAdmin::Layout::PageHelpers + + def initialize(user:) + @user = user + end + + def form_id + @form_id ||= "#{stimulus_id}--form-#{@user.id}" + end + + def tabs + [ + { + text: t('.account'), + href: solidus_admin.user_path(@user), + current: action_name == "edit", + }, + { + text: t('.addresses'), + href: solidus_admin.addresses_user_path(@user), + current: action_name == "addresses", + }, + { + text: t('.order_history'), + href: spree.orders_admin_user_path(@user), + # @todo: update this "current" logic once folded into new admin + current: action_name != "addresses", + }, + { + text: t('.items'), + href: spree.items_admin_user_path(@user), + # @todo: update this "current" logic once folded into new admin + current: action_name != "addresses", + }, + { + text: t('.store_credit'), + href: spree.admin_user_store_credits_path(@user), + # @todo: update this "current" logic once folded into new admin + current: action_name != "addresses", + }, + ] + 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 +end diff --git a/admin/app/components/solidus_admin/users/addresses/component.yml b/admin/app/components/solidus_admin/users/addresses/component.yml new file mode 100644 index 0000000000..6872c1d5cd --- /dev/null +++ b/admin/app/components/solidus_admin/users/addresses/component.yml @@ -0,0 +1,18 @@ +en: + title: "Users / %{email} / Addresses" + account: Account + addresses: Addresses + order_history: Order History + 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 + back: Back + billing_address: Billing Address + shipping_address: Shipping Address diff --git a/admin/app/components/solidus_admin/users/edit/component.rb b/admin/app/components/solidus_admin/users/edit/component.rb index 78f1b8a82e..1263af87cf 100644 --- a/admin/app/components/solidus_admin/users/edit/component.rb +++ b/admin/app/components/solidus_admin/users/edit/component.rb @@ -15,32 +15,31 @@ def tabs [ { text: t('.account'), - href: solidus_admin.users_path, - current: action_name == "show", + href: solidus_admin.user_path(@user), + current: action_name == "edit", }, { text: t('.addresses'), - href: spree.addresses_admin_user_path(@user), - # @todo: update this "current" logic once folded into new admin - current: action_name != "show", + href: solidus_admin.addresses_user_path(@user), + current: action_name == "addresses", }, { text: t('.order_history'), href: spree.orders_admin_user_path(@user), # @todo: update this "current" logic once folded into new admin - current: action_name != "show", + current: action_name != "edit", }, { text: t('.items'), href: spree.items_admin_user_path(@user), # @todo: update this "current" logic once folded into new admin - current: action_name != "show", + current: action_name != "edit", }, { text: t('.store_credit'), href: spree.admin_user_store_credits_path(@user), # @todo: update this "current" logic once folded into new admin - current: action_name != "show", + current: action_name != "edit", }, ] end diff --git a/admin/app/controllers/solidus_admin/users_controller.rb b/admin/app/controllers/solidus_admin/users_controller.rb index 5a679a4d2e..61afaa8b8b 100644 --- a/admin/app/controllers/solidus_admin/users_controller.rb +++ b/admin/app/controllers/solidus_admin/users_controller.rb @@ -3,6 +3,9 @@ module SolidusAdmin class UsersController < SolidusAdmin::BaseController include SolidusAdmin::ControllerHelpers::Search + include Spree::Core::ControllerHelpers::StrongParameters + + before_action :set_user, only: [:edit, :addresses, :update_addresses] search_scope(:all, default: true) search_scope(:customers) { _1.left_outer_joins(:role_users).where(role_users: { id: nil }) } @@ -23,9 +26,37 @@ def index end end - def edit - set_user + def addresses + respond_to do |format| + format.turbo_stream { render turbo_stream: '' } + format.html { render component('users/addresses').new(user: @user) } + end + end + + def update_addresses + set_address_from_params + + if @address.valid? && @user.update(user_params) + flash[:success] = t('.success') + + respond_to do |format| + format.turbo_stream { render turbo_stream: '' } + format.html { render component('users/addresses').new(user: @user) } + end + else + # @note It should be safe to mark the stock model validation messages as html_safe. + # rubocop:disable Rails/OutputSafety + flash[:error] = @address.errors.any? ? @address.errors.full_messages.join(", ").html_safe : t('.error') + # rubocop:enable Rails/OutputSafety + respond_to do |format| + format.turbo_stream { render turbo_stream: '' } + format.html { render component('users/addresses').new(user: @user), status: :unprocessable_entity } + end + end + end + + def edit respond_to do |format| format.html { render component('users/edit').new(user: @user) } end @@ -47,7 +78,26 @@ def set_user end def user_params - params.require(:user).permit(:user_id, permitted_user_attributes) + params.require(:user).permit( + :user_id, + permitted_user_attributes, + bill_address_attributes: permitted_address_attributes, + ship_address_attributes: permitted_address_attributes + ) + end + + # @note This method aims to fill the gap left by not having, or not knowing + # whether we will have the following on the current Spree.user_class. + # accepts_nested_attributes_for :ship_address + # accepts_nested_attributes_for :bill_address + # Not having these strips us of the ability to leverage a lot of the + # default Rails validations. + def set_address_from_params + if user_params.key?(:bill_address_attributes) + @address = Spree::Address.new(user_params[:bill_address_attributes]) + elsif user_params.key?(:ship_address_attributes) + @address = Spree::Address.new(user_params[:ship_address_attributes]) + end end def authorization_subject diff --git a/admin/config/locales/users.en.yml b/admin/config/locales/users.en.yml index c15e9fb0fa..bc94395fc4 100644 --- a/admin/config/locales/users.en.yml +++ b/admin/config/locales/users.en.yml @@ -4,3 +4,6 @@ en: title: "Users" destroy: success: "Users were successfully removed." + update_addresses: + success: "Address has been successfully updated." + error: "Address could not be updated." diff --git a/admin/config/routes.rb b/admin/config/routes.rb index afbb4be37c..ac4693d32d 100644 --- a/admin/config/routes.rb +++ b/admin/config/routes.rb @@ -45,7 +45,13 @@ end end - admin_resources :users, only: [:index, :edit, :destroy] + admin_resources :users, only: [:index, :edit, :destroy] do + member do + get :addresses + put :update_addresses + end + end + admin_resources :promotions, only: [:index, :destroy] admin_resources :properties, only: [:index, :destroy] admin_resources :option_types, only: [:index, :destroy], sortable: true diff --git a/admin/spec/features/users_spec.rb b/admin/spec/features/users_spec.rb index 36cd9eba91..68c9efa14b 100644 --- a/admin/spec/features/users_spec.rb +++ b/admin/spec/features/users_spec.rb @@ -109,4 +109,55 @@ expect(page).not_to have_content("newemail@example.com") end end + + context "when editing a user's addresses" do + before do + create(:user_with_addresses, email: "customer@example.com") + visit "/admin/users" + find_row("customer@example.com").click + click_on "Addresses" + end + + it "shows the address page" do + expect(page).to have_content("Users / customer@example.com / Addresses") + expect(page).to have_content("Lifetime Stats") + expect(page).to have_content("Billing Address") + expect(page).to be_axe_clean + end + + it "allows editing of the existing address" do + # Valid submission + within("form.bill_address") do + fill_in "Name", with: "Galadriel" + click_on "Update" + end + expect(page).to have_content("Address has been successfully updated.") + + # Valid submission + within("form.ship_address") do + fill_in "Name", with: "Elrond" + click_on "Update" + end + expect(page).to have_content("Address has been successfully updated.") + + # Cancel submission + within("form.bill_address") do + fill_in "Name", with: "Smeagol" + click_on "Cancel" + end + expect(page).to have_content("Users / customer@example.com / Addresses") + expect(page).not_to have_content("Smeagol") + + # Invalid submission + within("form.ship_address") do + fill_in "Name", with: "" + click_on "Update" + end + expect(page).to have_content("Name can't be blank") + + # The address forms weirdly only have values rather than actual text on the page. + expect(page).to have_field("user[bill_address_attributes][name]", with: "Galadriel") + expect(page).to have_field("user[ship_address_attributes][name]", with: "Elrond") + end + end end diff --git a/admin/spec/requests/solidus_admin/users_spec.rb b/admin/spec/requests/solidus_admin/users_spec.rb index 59df8ac3c6..267626abf2 100644 --- a/admin/spec/requests/solidus_admin/users_spec.rb +++ b/admin/spec/requests/solidus_admin/users_spec.rb @@ -4,7 +4,42 @@ RSpec.describe "SolidusAdmin::UsersController", type: :request do let(:admin_user) { create(:admin_user) } - let(:user) { create(:user) } + let(:user) { create(:user_with_addresses) } + let(:address) { create(:address) } + + let(:valid_address_params) do + { + user: { + bill_address_attributes: { + name: address.name, + address1: address.address1, + address2: address.address2, + city: address.city, + zipcode: address.zipcode, + state_id: address.state_id, + country_id: address.country_id, + phone: address.phone + } + } + } + end + + # Invalid due to missing "name" field. + let(:invalid_address_params) do + { + user: { + bill_address_attributes: { + address1: address.address1, + address2: address.address2, + city: address.city, + zipcode: address.zipcode, + state_id: address.state_id, + country_id: address.country_id, + phone: address.phone + } + } + } + end before do allow_any_instance_of(SolidusAdmin::BaseController).to receive(:spree_current_user).and_return(admin_user) @@ -44,6 +79,31 @@ end end + describe "GET /addresses" do + it "renders the addresses template with a 200 OK status" do + get solidus_admin.addresses_user_path(user) + expect(response).to have_http_status(:ok) + end + end + + describe "PUT /update_addresses" do + context "with valid address parameters" do + it "updates the user's address and redirects with a success message" do + put solidus_admin.update_addresses_user_path(user), params: valid_address_params + expect(response).to have_http_status(:ok) + expect(response.body).to include("Address has been successfully updated.") + end + end + + context "with invalid address parameters" do + it "does not update the user's address and renders the addresses component with errors" do + put solidus_admin.update_addresses_user_path(user), params: invalid_address_params + expect(response).to have_http_status(:unprocessable_entity) + expect(response.body).to include("Name can't be blank") + end + end + end + describe "search functionality" do before do create(:user, email: "test@example.com")