From ac8ee597335968072b9c42d9f1bf5f8e9ba7e957 Mon Sep 17 00:00:00 2001 From: Madeline Collier Date: Wed, 18 Sep 2024 21:22:23 +0200 Subject: [PATCH] Add new users admin edit page This migrates the `users/:id/edit` page to the new admin. It still relies on the old backend admin controller for the `#update` action as well as the other top level user tabs such as address, order history, and so on. Co-authored-by: benjamin wil --- .../users/edit/api_access/component.html.erb | 49 +++++++++++++++ .../users/edit/api_access/component.js | 9 +++ .../users/edit/api_access/component.rb | 7 +++ .../users/edit/api_access/component.yml | 10 +++ .../users/edit/component.html.erb | 62 ++++++++++++++++++ .../solidus_admin/users/edit/component.rb | 63 +++++++++++++++++++ .../solidus_admin/users/edit/component.yml | 16 +++++ .../solidus_admin/users/index/component.rb | 5 +- .../solidus_admin/users_controller.rb | 12 ++++ admin/config/routes.rb | 2 +- admin/spec/features/users_spec.rb | 59 ++++++++++++++++- .../spec/requests/solidus_admin/users_spec.rb | 59 +++++++++++++++++ core/config/locales/en.yml | 1 + 13 files changed, 350 insertions(+), 4 deletions(-) create mode 100644 admin/app/components/solidus_admin/users/edit/api_access/component.html.erb create mode 100644 admin/app/components/solidus_admin/users/edit/api_access/component.js create mode 100644 admin/app/components/solidus_admin/users/edit/api_access/component.rb create mode 100644 admin/app/components/solidus_admin/users/edit/api_access/component.yml create mode 100644 admin/app/components/solidus_admin/users/edit/component.html.erb create mode 100644 admin/app/components/solidus_admin/users/edit/component.rb create mode 100644 admin/app/components/solidus_admin/users/edit/component.yml create mode 100644 admin/spec/requests/solidus_admin/users_spec.rb diff --git a/admin/app/components/solidus_admin/users/edit/api_access/component.html.erb b/admin/app/components/solidus_admin/users/edit/api_access/component.html.erb new file mode 100644 index 00000000000..96d4e00ed9a --- /dev/null +++ b/admin/app/components/solidus_admin/users/edit/api_access/component.html.erb @@ -0,0 +1,49 @@ +<%= render component('ui/panel').new(title: t('.api_access')) do %> +
+ <% if @user.spree_api_key.present? %> +
+

<%= t('.key') %>

+ <% if @user == helpers.current_solidus_admin_user %> + <%= @user.spree_api_key %> + <% else %> + (<%= t('spree.hidden') %>) + <% end %> +
+ +
+ <%= form_with url: spree.admin_user_api_key_path(@user), method: :delete, local: true, html: { class: 'clear_api_key inline-flex' } do %> + <%= render component("ui/button").new( + text: t('.clear_key'), + scheme: :secondary, + type: :submit, + "data-action": "click->#{stimulus_id}#confirm", + "data-#{stimulus_id}-message-param": t(".confirm_clear_key"), + ) %> + <% end %> + + <%= form_with url: spree.admin_user_api_key_path(@user), method: :post, local: true, html: { class: 'regen_api_key inline-flex' } do %> + <%= render component("ui/button").new( + text: t('.regenerate_key'), + scheme: :secondary, + type: :submit, + "data-action": "click->#{stimulus_id}#confirm", + "data-#{stimulus_id}-message-param": t(".confirm_regenerate_key"), + ) %> + <% end %> +
+ + <% else %> +
<%= t('.no_key') %>
+
+
+ <%= form_with url: spree.admin_user_api_key_path(@user), method: :post, local: true, html: { class: 'generate_api_key inline-flex' } do %> + <%= render component("ui/button").new( + text: t('.generate_key'), + type: :submit, + ) %> + <% end %> +
+
+ <% end %> +
+<% end %> diff --git a/admin/app/components/solidus_admin/users/edit/api_access/component.js b/admin/app/components/solidus_admin/users/edit/api_access/component.js new file mode 100644 index 00000000000..910294c5462 --- /dev/null +++ b/admin/app/components/solidus_admin/users/edit/api_access/component.js @@ -0,0 +1,9 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + confirm(event) { + if (!confirm(event.params.message)) { + event.preventDefault() + } + } +} diff --git a/admin/app/components/solidus_admin/users/edit/api_access/component.rb b/admin/app/components/solidus_admin/users/edit/api_access/component.rb new file mode 100644 index 00000000000..be6df3729b8 --- /dev/null +++ b/admin/app/components/solidus_admin/users/edit/api_access/component.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class SolidusAdmin::Users::Edit::ApiAccess::Component < SolidusAdmin::BaseComponent + def initialize(user:) + @user = user + end +end diff --git a/admin/app/components/solidus_admin/users/edit/api_access/component.yml b/admin/app/components/solidus_admin/users/edit/api_access/component.yml new file mode 100644 index 00000000000..e865ba47c30 --- /dev/null +++ b/admin/app/components/solidus_admin/users/edit/api_access/component.yml @@ -0,0 +1,10 @@ +en: + api_access: API Access + no_key: No key + key: "Key" + generate_key: Generate API key + clear_key: Clear key + regenerate_key: Regenerate key + hidden: Hidden + confirm_clear_key: Are you sure you want to clear this user's API key? It will invalidate the existing key. + confirm_regenerate_key: Are you sure you want to regenerate this user's API key? It will invalidate the existing key. diff --git a/admin/app/components/solidus_admin/users/edit/component.html.erb b/admin/app/components/solidus_admin/users/edit/component.html.erb new file mode 100644 index 00000000000..b9b8cbe4488 --- /dev/null +++ b/admin/app/components/solidus_admin/users/edit/component.html.erb @@ -0,0 +1,62 @@ +<%= page do %> + <%= page_header do %> + <%= page_header_back(solidus_admin.users_path) %> + <%= page_header_title(t(".title", email: @user.email)) %> + + <% # @todo: I am not sure how we want to handle Cancan stuff in the new admin. %> + <% # if can?(:admin, Spree::Order) && can?(:create, Spree::Order) %> + <%= 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 %> + <% 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: Spree.user_class.model_name.human) do %> + <%= form_for @user, url: solidus_admin.user_path(@user), html: { id: form_id, autocomplete: "off" } do |f| %> +
+ <%= render component("ui/forms/field").text_field(f, :email) %> +
+
+ <%= render component("ui/forms/field").text_field(f, :password) %> +
+
+ <%= render component("ui/forms/field").text_field(f, :password_confirmation) %> +
+
+ <%= render component("ui/checkbox_row").new(options: role_options, row_title: "Roles", form: f, method: "spree_role_ids", layout: :subsection) %> +
+
+ <%= render component("ui/button").new(tag: :button, text: t(".update"), form: form_id) %> + <%= render component("ui/button").new(tag: :a, text: t(".cancel"), href: solidus_admin.user_path(@user), scheme: :secondary) %> +
+ <% end %> + <% end %> + + <%= render component("users/edit/api_access").new(user: @user) %> + + <% 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/edit/component.rb b/admin/app/components/solidus_admin/users/edit/component.rb new file mode 100644 index 00000000000..78f1b8a82eb --- /dev/null +++ b/admin/app/components/solidus_admin/users/edit/component.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +class SolidusAdmin::Users::Edit::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.users_path, + current: action_name == "show", + }, + { + text: t('.addresses'), + href: spree.addresses_admin_user_path(@user), + # @todo: update this "current" logic once folded into new admin + current: action_name != "show", + }, + { + 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", + }, + { + text: t('.items'), + href: spree.items_admin_user_path(@user), + # @todo: update this "current" logic once folded into new admin + current: action_name != "show", + }, + { + 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", + }, + ] + 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 } + end + end +end diff --git a/admin/app/components/solidus_admin/users/edit/component.yml b/admin/app/components/solidus_admin/users/edit/component.yml new file mode 100644 index 00000000000..01b3cf230ac --- /dev/null +++ b/admin/app/components/solidus_admin/users/edit/component.yml @@ -0,0 +1,16 @@ +en: + title: "Users / %{email}" + 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 diff --git a/admin/app/components/solidus_admin/users/index/component.rb b/admin/app/components/solidus_admin/users/index/component.rb index 81fb38444ff..0b911061d90 100644 --- a/admin/app/components/solidus_admin/users/index/component.rb +++ b/admin/app/components/solidus_admin/users/index/component.rb @@ -14,7 +14,7 @@ def search_url end def row_url(user) - spree.admin_user_path(user) + solidus_admin.edit_user_path(user) end def page_actions @@ -104,7 +104,8 @@ def last_login(user) t( '.last_login.login_time_ago', - # @note The second `.try` is only here for the specs to work. + # @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 diff --git a/admin/app/controllers/solidus_admin/users_controller.rb b/admin/app/controllers/solidus_admin/users_controller.rb index 7f674b4e09e..5a679a4d2e1 100644 --- a/admin/app/controllers/solidus_admin/users_controller.rb +++ b/admin/app/controllers/solidus_admin/users_controller.rb @@ -23,6 +23,14 @@ def index end end + def edit + set_user + + respond_to do |format| + format.html { render component('users/edit').new(user: @user) } + end + end + def destroy @users = Spree.user_class.where(id: params[:id]) @@ -34,6 +42,10 @@ def destroy private + def set_user + @user = Spree.user_class.find(params[:id]) + end + def user_params params.require(:user).permit(:user_id, permitted_user_attributes) end diff --git a/admin/config/routes.rb b/admin/config/routes.rb index 61f5a729799..afbb4be37c5 100644 --- a/admin/config/routes.rb +++ b/admin/config/routes.rb @@ -45,7 +45,7 @@ end end - admin_resources :users, only: [:index, :destroy] + admin_resources :users, only: [:index, :edit, :destroy] 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 f0e88296031..36cd9eba915 100644 --- a/admin/spec/features/users_spec.rb +++ b/admin/spec/features/users_spec.rb @@ -3,7 +3,11 @@ require "spec_helper" describe "Users", :js, type: :feature do - before { sign_in create(:admin_user, email: "admin@example.com") } + let(:admin) { create(:admin_user, email: "admin@example.com") } + + before do + sign_in admin + end it "lists users and allows deleting them" do create(:user, email: "customer@example.com") @@ -52,4 +56,57 @@ expect(page).not_to have_content("Never") end end + + context "when editing an existing user" do + before do + # This is needed for the actions which are still powered by the backend + # and not the new admin. (#update, etc.) + stub_authorization!(admin) + + create(:user, email: "customer@example.com") + visit "/admin/users" + find_row("customer@example.com").click + end + + it "shows the edit page" do + expect(page).to have_content("Users / customer@example.com") + expect(page).to have_content("Lifetime Stats") + expect(page).to have_content("Roles") + expect(find("label", text: /admin/i).find("input[type=checkbox]").checked?).to eq(false) + end + + it "allows editing of the existing user" do + # API key interactions + expect(page).to have_content("No key") + click_on "Generate API key" + expect(page).to have_content("Key generated") + expect(page).to have_content("(hidden)") + + click_on "Regenerate key" + expect(page).to have_content("Key generated") + expect(page).to have_content("(hidden)") + + click_on "Clear key" + expect(page).to have_content("Key cleared") + expect(page).to have_content("No key") + + # Update user + within("form.edit_user") do + fill_in "Email", with: "dogtown@example.com" + find("label", text: /admin/i).find("input[type=checkbox]").check + click_on "Update" + end + + expect(page).to have_content("Users / dogtown@example.com") + expect(find("label", text: /admin/i).find("input[type=checkbox]").checked?).to eq(true) + + # Cancel out of editing + within("form.edit_user") do + fill_in "Email", with: "newemail@example.com" + click_on "Cancel" + end + + expect(page).not_to have_content("newemail@example.com") + end + end end diff --git a/admin/spec/requests/solidus_admin/users_spec.rb b/admin/spec/requests/solidus_admin/users_spec.rb new file mode 100644 index 00000000000..59df8ac3c6c --- /dev/null +++ b/admin/spec/requests/solidus_admin/users_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "SolidusAdmin::UsersController", type: :request do + let(:admin_user) { create(:admin_user) } + let(:user) { create(:user) } + + before do + allow_any_instance_of(SolidusAdmin::BaseController).to receive(:spree_current_user).and_return(admin_user) + end + + describe "GET /index" do + it "renders the index template with a 200 OK status" do + get solidus_admin.users_path + expect(response).to have_http_status(:ok) + end + end + + describe "GET /edit" do + it "renders the edit template with a 200 OK status" do + get solidus_admin.edit_user_path(user) + expect(response).to have_http_status(:ok) + 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 + user + + expect { + delete solidus_admin.user_path(user) + }.to change(Spree.user_class, :count).by(-1) + + expect(response).to redirect_to(solidus_admin.users_path) + expect(response).to have_http_status(:see_other) + end + + it "displays a success flash message after deletion" do + delete solidus_admin.user_path(user) + follow_redirect! + expect(response.body).to include("Users were successfully removed.") + end + end + + describe "search functionality" do + before do + create(:user, email: "test@example.com") + create(:user, email: "another@example.com") + end + + it "filters users based on search parameters" do + get solidus_admin.users_path, params: { q: { email_cont: "test" } } + expect(response.body).to include("test@example.com") + expect(response.body).not_to include("another@example.com") + end + end +end diff --git a/core/config/locales/en.yml b/core/config/locales/en.yml index d77fd1e12dd..5968fab8ad5 100644 --- a/core/config/locales/en.yml +++ b/core/config/locales/en.yml @@ -1855,6 +1855,7 @@ en: order_approved: Order approved order_canceled: Order canceled order_completed: Order completed + order_count: Order Count order_details: Order Details order_email_resent: Order Email Resent order_information: Order Information