diff --git a/app/assets/javascripts/admin/addon/components/bulk-user-delete-confirmation.gjs b/app/assets/javascripts/admin/addon/components/bulk-user-delete-confirmation.gjs
new file mode 100644
index 00000000000..dbf17321829
--- /dev/null
+++ b/app/assets/javascripts/admin/addon/components/bulk-user-delete-confirmation.gjs
@@ -0,0 +1,199 @@
+import Component from "@glimmer/component";
+import { tracked } from "@glimmer/tracking";
+import { on } from "@ember/modifier";
+import { action } from "@ember/object";
+import { service } from "@ember/service";
+import { TrackedArray } from "@ember-compat/tracked-built-ins";
+import DButton from "discourse/components/d-button";
+import DModal from "discourse/components/d-modal";
+import { ajax } from "discourse/lib/ajax";
+import { extractError } from "discourse/lib/ajax-error";
+import { bind } from "discourse-common/utils/decorators";
+import { i18n } from "discourse-i18n";
+
+const BULK_DELETE_CHANNEL = "/bulk-user-delete";
+
+export default class BulkUserDeleteConfirmation extends Component {
+ @service messageBus;
+
+ @tracked confirmButtonDisabled = true;
+ @tracked deleteStarted = false;
+ @tracked logs = new TrackedArray();
+ failedUsernames = [];
+
+ callAfterBulkDelete = false;
+
+ constructor() {
+ super(...arguments);
+ this.messageBus.subscribe(BULK_DELETE_CHANNEL, this.onDeleteProgress);
+ }
+
+ willDestroy() {
+ super.willDestroy(...arguments);
+ this.messageBus.unsubscribe(BULK_DELETE_CHANNEL, this.onDeleteProgress);
+ }
+
+ get confirmDeletePhrase() {
+ return i18n(
+ "admin.users.bulk_actions.delete.confirmation_modal.confirmation_phrase",
+ { count: this.args.model.userIds.length }
+ );
+ }
+
+ #logError(line) {
+ this.#log(line, "error");
+ }
+
+ #logSuccess(line) {
+ this.#log(line, "success");
+ }
+
+ #logNeutral(line) {
+ this.#log(line, "neutral");
+ }
+
+ #log(line, type) {
+ this.logs.push({
+ line,
+ type,
+ });
+ }
+
+ @bind
+ onDeleteProgress(data) {
+ if (data.success) {
+ this.#logSuccess(
+ i18n(
+ "admin.users.bulk_actions.delete.confirmation_modal.user_delete_succeeded",
+ {
+ position: data.position,
+ total: data.total,
+ username: data.username,
+ }
+ )
+ );
+ } else if (data.failed) {
+ this.failedUsernames.push(data.username);
+ this.#logError(
+ i18n(
+ "admin.users.bulk_actions.delete.confirmation_modal.user_delete_failed",
+ {
+ position: data.position,
+ total: data.total,
+ username: data.username,
+ error: data.error,
+ }
+ )
+ );
+ }
+
+ if (data.position === data.total) {
+ this.callAfterBulkDelete = true;
+ this.#logNeutral(
+ i18n(
+ "admin.users.bulk_actions.delete.confirmation_modal.bulk_delete_finished"
+ )
+ );
+ if (this.failedUsernames.length > 0) {
+ this.#logNeutral(
+ i18n(
+ "admin.users.bulk_actions.delete.confirmation_modal.failed_to_delete_users"
+ )
+ );
+ for (const username of this.failedUsernames) {
+ this.#logNeutral(`* ${username}`);
+ }
+ }
+ }
+ }
+
+ @action
+ onPromptInput(event) {
+ this.confirmButtonDisabled =
+ event.target.value.toLowerCase() !== this.confirmDeletePhrase;
+ }
+
+ @action
+ async startDelete() {
+ this.deleteStarted = true;
+ this.confirmButtonDisabled = true;
+ this.#logNeutral(
+ i18n(
+ "admin.users.bulk_actions.delete.confirmation_modal.bulk_delete_starting"
+ )
+ );
+
+ try {
+ await ajax("/admin/users/destroy-bulk.json", {
+ type: "DELETE",
+ data: { user_ids: this.args.model.userIds },
+ });
+ this.callAfterBulkDelete = true;
+ } catch (err) {
+ this.#logError(extractError(err));
+ this.confirmButtonDisabled = false;
+ }
+ }
+
+ @action
+ closeModal() {
+ this.args.closeModal();
+ if (this.callAfterBulkDelete) {
+ this.args.model?.afterBulkDelete();
+ }
+ }
+
+
+
+ <:body>
+ {{#if this.deleteStarted}}
+
+ {{#each this.logs as |entry|}}
+
+ {{entry.line}}
+
+ {{/each}}
+
+
+
+ {{else}}
+ {{i18n
+ "admin.users.bulk_actions.delete.confirmation_modal.prompt_text"
+ count=@model.userIds.length
+ confirmation_phrase=this.confirmDeletePhrase
+ }}
+
+
+ {{/if}}
+
+ <:footer>
+
+
+
+
+
+}
diff --git a/app/assets/javascripts/admin/addon/controllers/admin-users-list-show.js b/app/assets/javascripts/admin/addon/controllers/admin-users-list-show.js
index 388a898a9a8..b9a3b9960ab 100644
--- a/app/assets/javascripts/admin/addon/controllers/admin-users-list-show.js
+++ b/app/assets/javascripts/admin/addon/controllers/admin-users-list-show.js
@@ -2,25 +2,25 @@ import { tracked } from "@glimmer/tracking";
import Controller from "@ember/controller";
import { action } from "@ember/object";
import { service } from "@ember/service";
-import { observes } from "@ember-decorators/object";
import { computedI18n } from "discourse/lib/computed";
-import { ajax } from "discourse/lib/ajax";
-import { extractError } from "discourse/lib/ajax-error";
import CanCheckEmails from "discourse/mixins/can-check-emails";
import { INPUT_DELAY } from "discourse-common/config/environment";
import discourseDebounce from "discourse-common/lib/debounce";
-import discourseComputed from "discourse-common/utils/decorators";
+import discourseComputed, { bind } from "discourse-common/utils/decorators";
import { i18n } from "discourse-i18n";
+import BulkUserDeleteConfirmation from "admin/components/bulk-user-delete-confirmation";
import AdminUser from "admin/models/admin-user";
export default class AdminUsersListShowController extends Controller.extend(
CanCheckEmails
) {
@service dialog;
+ @service modal;
@tracked bulkSelect = false;
@tracked displayBulkActions = false;
- @tracked bulkSelectedUsers = null;
+ @tracked bulkSelectedUserIdsSet = new Set();
+ @tracked bulkSelectedUsersMap = {};
query = null;
order = null;
@@ -56,11 +56,6 @@ export default class AdminUsersListShowController extends Controller.extend(
return colCount;
}
- @observes("listFilter")
- _filterUsers() {
- discourseDebounce(this, this.resetFilters, INPUT_DELAY);
- }
-
resetFilters() {
this._page = 1;
this._results = [];
@@ -96,6 +91,12 @@ export default class AdminUsersListShowController extends Controller.extend(
});
}
+ @action
+ onListFilterChange(event) {
+ this.set("listFilter", event.target.value);
+ discourseDebounce(this, this.resetFilters, INPUT_DELAY);
+ }
+
@action
loadMore() {
this._page += 1;
@@ -120,48 +121,36 @@ export default class AdminUsersListShowController extends Controller.extend(
toggleBulkSelect() {
this.bulkSelect = !this.bulkSelect;
this.displayBulkActions = false;
- this.bulkSelectedUsers = null;
+ this.bulkSelectedUsersMap = {};
+ this.bulkSelectedUserIdsSet = new Set();
}
@action
bulkSelectItemToggle(userId, event) {
- if (!this.bulkSelectedUsers) {
- this.bulkSelectedUsers = {};
- }
-
if (event.target.checked) {
- this.bulkSelectedUsers[userId] = 1;
+ this.bulkSelectedUserIdsSet.add(userId);
+ this.bulkSelectedUsersMap[userId] = 1;
} else {
- delete this.bulkSelectedUsers[userId];
+ this.bulkSelectedUserIdsSet.delete(userId);
+ delete this.bulkSelectedUsersMap[userId];
}
- this.displayBulkActions = Object.keys(this.bulkSelectedUsers).length > 0;
+ this.displayBulkActions = this.bulkSelectedUserIdsSet.size > 0;
+ }
+
+ @bind
+ async afterBulkDelete() {
+ await this.resetFilters();
+ this.bulkSelectedUsersMap = {};
+ this.bulkSelectedUserIdsSet = new Set();
+ this.displayBulkActions = false;
}
@action
- performBulkDelete() {
- const userIds = Object.keys(this.bulkSelectedUsers);
- const count = userIds.length;
- this.dialog.deleteConfirm({
- title: I18n.t("admin.users.bulk_actions.confirm_delete_title", {
- count,
- }),
- message: I18n.t("admin.users.bulk_actions.confirm_delete_body", {
- count,
- }),
- confirmButtonClass: "btn-danger",
- confirmButtonIcon: "trash-can",
- didConfirm: async () => {
- try {
- await ajax("/admin/users/destroy-bulk.json", {
- type: "DELETE",
- data: { user_ids: userIds },
- });
- await this.resetFilters();
- this.bulkSelectedUsers = null;
- this.displayBulkActions = false;
- } catch (err) {
- this.dialog.alert(extractError(err));
- }
+ openBulkDeleteConfirmation() {
+ this.modal.show(BulkUserDeleteConfirmation, {
+ model: {
+ userIds: Array.from(this.bulkSelectedUserIdsSet),
+ afterBulkDelete: this.afterBulkDelete,
},
});
}
diff --git a/app/assets/javascripts/admin/addon/routes/admin-users-list-show.js b/app/assets/javascripts/admin/addon/routes/admin-users-list-show.js
index 532fd150ad0..81970884e28 100644
--- a/app/assets/javascripts/admin/addon/routes/admin-users-list-show.js
+++ b/app/assets/javascripts/admin/addon/routes/admin-users-list-show.js
@@ -24,7 +24,8 @@ export default class AdminUsersListShowRoute extends DiscourseRoute {
listFilter: transition.to.queryParams.username,
query: params.filter,
refreshing: false,
- bulkSelectedUsers: null,
+ bulkSelectedUsersMap: {},
+ bulkSelectedUserIdsSet: new Set(),
displayBulkActions: false,
});
diff --git a/app/assets/javascripts/admin/addon/templates/users-list-show.hbs b/app/assets/javascripts/admin/addon/templates/users-list-show.hbs
index 8795cbbc9eb..56160ae0738 100644
--- a/app/assets/javascripts/admin/addon/templates/users-list-show.hbs
+++ b/app/assets/javascripts/admin/addon/templates/users-list-show.hbs
@@ -21,10 +21,12 @@
-
{{#if this.displayBulkActions}}
@@ -41,9 +43,11 @@
@@ -173,12 +177,38 @@
>
{{#if this.bulkSelect}}
-
+ {{#if user.can_be_deleted}}
+
+ {{else}}
+
+ <:trigger>
+
+
+ <:content>
+ {{#if user.admin}}
+ {{i18n
+ "admin.users.bulk_actions.admin_cant_be_deleted"
+ }}
+ {{else}}
+ {{i18n
+ "admin.users.bulk_actions.too_many_or_old_posts"
+ }}
+ {{/if}}
+
+
+ {{/if}}
{{/if}}
-
-
- {{else}}
+ {{else if (not this.refreshing)}}
{{i18n "search.no_results"}}
{{/if}}
+
\ No newline at end of file
diff --git a/app/assets/stylesheets/common/admin/admin_base.scss b/app/assets/stylesheets/common/admin/admin_base.scss
index 95f4b94eadc..324754fcdcd 100644
--- a/app/assets/stylesheets/common/admin/admin_base.scss
+++ b/app/assets/stylesheets/common/admin/admin_base.scss
@@ -1105,3 +1105,4 @@ a.inline-editable-field {
@import "common/admin/mini_profiler";
@import "common/admin/schema_theme_setting_editor";
@import "common/admin/customize_themes_show_schema";
+@import "common/admin/admin_bulk_users_delete_modal";
diff --git a/app/assets/stylesheets/common/admin/admin_bulk_users_delete_modal.scss b/app/assets/stylesheets/common/admin/admin_bulk_users_delete_modal.scss
new file mode 100644
index 00000000000..4789926f4ba
--- /dev/null
+++ b/app/assets/stylesheets/common/admin/admin_bulk_users_delete_modal.scss
@@ -0,0 +1,23 @@
+.bulk-user-delete-confirmation {
+ &__progress {
+ font-family: var(--d-font-family--monospace);
+ max-height: 400px;
+ background: var(--blend-primary-secondary-5);
+ padding: 1em;
+ overflow-y: auto;
+ }
+ &__progress-line {
+ overflow-anchor: none;
+
+ &.-success {
+ color: var(--success);
+ }
+ &.-error {
+ color: var(--danger);
+ }
+ }
+ &__progress-anchor {
+ overflow-anchor: auto;
+ height: 1px;
+ }
+}
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index 66bac1c336e..29fa6f506eb 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -414,6 +414,8 @@ class Admin::UsersController < Admin::StaffController
on_failed_policy(:can_delete_users) do
render json: failed_json.merge(errors: [I18n.t("user.cannot_bulk_delete")]), status: 403
end
+
+ on_model_not_found(:users) { render json: failed_json, status: 404 }
end
end
end
diff --git a/app/serializers/admin_user_list_serializer.rb b/app/serializers/admin_user_list_serializer.rb
index 4ce713fb145..e705cc26e50 100644
--- a/app/serializers/admin_user_list_serializer.rb
+++ b/app/serializers/admin_user_list_serializer.rb
@@ -24,7 +24,8 @@ class AdminUserListSerializer < BasicUserSerializer
:silenced_till,
:time_read,
:staged,
- :second_factor_enabled
+ :second_factor_enabled,
+ :can_be_deleted
%i[days_visited posts_read_count topics_entered post_count].each do |sym|
attributes sym
@@ -111,4 +112,8 @@ class AdminUserListSerializer < BasicUserSerializer
def second_factor_enabled
true
end
+
+ def can_be_deleted
+ scope.can_delete_user?(object)
+ end
end
diff --git a/app/services/user/bulk_destroy.rb b/app/services/user/bulk_destroy.rb
index 775284c2374..ca36ce63b24 100644
--- a/app/services/user/bulk_destroy.rb
+++ b/app/services/user/bulk_destroy.rb
@@ -16,7 +16,11 @@ class User::BulkDestroy
private
def fetch_users(params:)
- User.where(id: params.user_ids.to_a)
+ ids = params.user_ids.to_a
+ # this order cluase ensures we retrieve the users in the same order as the
+ # IDs in the param. we do this to ensure the users are deleted in the same
+ # order as they're selected in the UI
+ User.where(id: ids).order(DB.sql_fragment("array_position(ARRAY[?], users.id)", ids))
end
def can_delete_users(guardian:, users:)
@@ -24,12 +28,42 @@ class User::BulkDestroy
end
def delete(users:, guardian:)
- users.each do |u|
- UserDestroyer.new(guardian.user).destroy(
- u,
- delete_posts: true,
- context: I18n.t("staff_action_logs.bulk_user_delete", users: users.map(&:id).inspect),
+ users.each.with_index do |u, index|
+ position = index + 1
+ success =
+ UserDestroyer.new(guardian.user).destroy(
+ u,
+ delete_posts: true,
+ prepare_for_destroy: true,
+ context: I18n.t("staff_action_logs.bulk_user_delete", users: users.map(&:id).inspect),
+ )
+
+ if success
+ publish_progress(
+ guardian.user,
+ { position:, username: u.username, total: users.size, success: true },
+ )
+ else
+ publish_progress(
+ guardian.user,
+ {
+ position:,
+ username: u.username,
+ total: users.size,
+ failed: true,
+ error: u.errors.full_messages.join(", "),
+ },
+ )
+ end
+ rescue => err
+ publish_progress(
+ guardian.user,
+ { position:, username: u.username, total: users.size, failed: true, error: err.message },
)
end
end
+
+ def publish_progress(actor, data)
+ ::MessageBus.publish("/bulk-user-delete", data, user_ids: [actor.id])
+ end
end
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 523cc0c4fb8..994a758157e 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -6641,13 +6641,27 @@ en:
hide_emails: "Hide Emails"
bulk_actions:
title: "Bulk actions"
- delete: "Delete users…"
- confirm_delete_title:
- one: "Delete %{count} user?"
- other: "Delete %{count} users?"
- confirm_delete_body:
- one: "Are you sure you want to delete %{count} user? This action is permanent and cannot be reversed."
- other: "Are you sure you want to delete %{count} users? This action is permanent and cannot be reversed."
+ admin_cant_be_deleted: "This user can't be deleted because they're an admin"
+ too_many_or_old_posts: "This user can't be deleted they have too many posts or a very old post"
+ delete:
+ label: "Delete users…"
+ confirmation_modal:
+ prompt_text:
+ one: "You're about to delete %{count} user permanently. Type \"%{confirmation_phrase}\" below to proceed:"
+ other: "You're about to delete %{count} users permanently. Type \"%{confirmation_phrase}\" below to proceed:"
+ confirmation_phrase:
+ one: "delete %{count} user"
+ other: "delete %{count} users"
+ close: "Close"
+ confirm: "Delete"
+ title:
+ one: "Delete %{count} user"
+ other: "Delete %{count} users"
+ bulk_delete_starting: "Starting bulk delete…"
+ user_delete_succeeded: "[%{position}/%{total}] Successfully deleted @%{username}"
+ user_delete_failed: "[%{position}/%{total}] Failed to delete @%{username} - %{error}"
+ bulk_delete_finished: "Bulk delete operation completed."
+ failed_to_delete_users: "The following users failed to be deleted:"
nav:
new: "New"
active: "Active"
diff --git a/spec/requests/admin/users_controller_spec.rb b/spec/requests/admin/users_controller_spec.rb
index 81ab125741e..6803524db48 100644
--- a/spec/requests/admin/users_controller_spec.rb
+++ b/spec/requests/admin/users_controller_spec.rb
@@ -1445,6 +1445,12 @@ RSpec.describe Admin::UsersController do
expect(User.where(id: deleted_users.map(&:id)).count).to eq(0)
end
+ it "responds with 404 when sending an empty user_ids list" do
+ delete "/admin/users/destroy-bulk.json", params: { user_ids: [] }
+
+ expect(response.status).to eq(404)
+ end
+
it "doesn't allow deleting a user that can't be deleted" do
deleted_users[0].update!(admin: true)
diff --git a/spec/system/admin_users_list_spec.rb b/spec/system/admin_users_list_spec.rb
index bc571946804..e92634a597a 100644
--- a/spec/system/admin_users_list_spec.rb
+++ b/spec/system/admin_users_list_spec.rb
@@ -2,70 +2,141 @@
describe "Admin Users Page", type: :system do
fab!(:current_user) { Fabricate(:admin) }
+ fab!(:another_admin) { Fabricate(:admin) }
fab!(:users) { Fabricate.times(3, :user) }
let(:admin_users_page) { PageObjects::Pages::AdminUsers.new }
- let(:dialog) { PageObjects::Components::Dialog.new }
before { sign_in(current_user) }
- it "has a button that toggles the bulk select checkboxes" do
- admin_users_page.visit
+ describe "bulk user delete" do
+ let(:confirmation_modal) { PageObjects::Modals::BulkUserDeleteConfirmation.new }
- expect(admin_users_page).to have_users(users.map(&:id))
+ it "disables checkboxes for users that can't be deleted" do
+ admin_users_page.visit
- expect(admin_users_page.user_row(users[0].id)).to have_no_bulk_select_checkbox
- expect(admin_users_page.user_row(users[1].id)).to have_no_bulk_select_checkbox
- expect(admin_users_page.user_row(users[2].id)).to have_no_bulk_select_checkbox
+ admin_users_page.bulk_select_button.click
- admin_users_page.bulk_select_button.click
+ expect(admin_users_page.user_row(current_user.id).bulk_select_checkbox.disabled?).to eq(true)
+ expect(admin_users_page.user_row(another_admin.id).bulk_select_checkbox.disabled?).to eq(true)
+ expect(admin_users_page.user_row(users[0].id).bulk_select_checkbox.disabled?).to eq(false)
- expect(admin_users_page.user_row(users[0].id)).to have_bulk_select_checkbox
- expect(admin_users_page.user_row(users[1].id)).to have_bulk_select_checkbox
- expect(admin_users_page.user_row(users[2].id)).to have_bulk_select_checkbox
+ admin_users_page.user_row(another_admin.id).bulk_select_checkbox.hover
+ expect(PageObjects::Components::Tooltips.new("bulk-delete-unavailable-reason")).to be_present(
+ text: I18n.t("admin_js.admin.users.bulk_actions.admin_cant_be_deleted"),
+ )
+ end
- expect(admin_users_page).to have_no_bulk_actions_dropdown
+ it "has a button that toggles the bulk select checkboxes" do
+ admin_users_page.visit
- admin_users_page.user_row(users[0].id).bulk_select_checkbox.click
+ expect(admin_users_page).to have_users(users.map(&:id))
- expect(admin_users_page).to have_bulk_actions_dropdown
+ expect(admin_users_page.user_row(users[0].id)).to have_no_bulk_select_checkbox
+ expect(admin_users_page.user_row(users[1].id)).to have_no_bulk_select_checkbox
+ expect(admin_users_page.user_row(users[2].id)).to have_no_bulk_select_checkbox
- admin_users_page.user_row(users[1].id).bulk_select_checkbox.click
- admin_users_page.bulk_actions_dropdown.expand
- admin_users_page.bulk_actions_dropdown.option(".bulk-delete").click
+ admin_users_page.bulk_select_button.click
- expect(dialog).to be_open
- dialog.click_danger
+ expect(admin_users_page.user_row(users[0].id)).to have_bulk_select_checkbox
+ expect(admin_users_page.user_row(users[1].id)).to have_bulk_select_checkbox
+ expect(admin_users_page.user_row(users[2].id)).to have_bulk_select_checkbox
- deleted_ids = users[0..1].map(&:id)
- expect(admin_users_page).to have_no_users(deleted_ids)
- expect(User.where(id: deleted_ids).count).to eq(0)
- end
+ expect(admin_users_page).to have_no_bulk_actions_dropdown
- it "remembers selected users when the user list refreshes due to search" do
- admin_users_page.visit
- admin_users_page.bulk_select_button.click
- admin_users_page.search_input.fill_in(with: users[0].username)
- admin_users_page.user_row(users[0].id).bulk_select_checkbox.click
+ admin_users_page.user_row(users[0].id).bulk_select_checkbox.click
- admin_users_page.search_input.fill_in(with: users[1].username)
- admin_users_page.user_row(users[1].id).bulk_select_checkbox.click
+ expect(admin_users_page).to have_bulk_actions_dropdown
- admin_users_page.search_input.fill_in(with: "")
+ admin_users_page.user_row(users[1].id).bulk_select_checkbox.click
+ admin_users_page.bulk_actions_dropdown.expand
+ admin_users_page.bulk_actions_dropdown.option(".bulk-delete").click
- expect(admin_users_page).to have_users(users.map(&:id))
- expect(admin_users_page.user_row(users[0].id).bulk_select_checkbox).to be_checked
- expect(admin_users_page.user_row(users[1].id).bulk_select_checkbox).to be_checked
- expect(admin_users_page.user_row(users[2].id).bulk_select_checkbox).not_to be_checked
+ expect(confirmation_modal).to be_open
+ expect(confirmation_modal).to have_confirm_button_disabled
- admin_users_page.bulk_actions_dropdown.expand
- admin_users_page.bulk_actions_dropdown.option(".bulk-delete").click
+ confirmation_modal.fill_in_confirmation_phase(user_count: 3)
+ expect(confirmation_modal).to have_confirm_button_disabled
- expect(dialog).to be_open
- dialog.click_danger
+ confirmation_modal.fill_in_confirmation_phase(user_count: 2)
+ expect(confirmation_modal).to have_confirm_button_enabled
- deleted_ids = users[0..1].map(&:id)
- expect(admin_users_page).to have_no_users(deleted_ids)
- expect(User.where(id: deleted_ids).count).to eq(0)
+ confirmation_modal.confirm_button.click
+
+ expect(confirmation_modal).to have_successful_log_entry_for_user(
+ user: users[0],
+ position: 1,
+ total: 2,
+ )
+ expect(confirmation_modal).to have_successful_log_entry_for_user(
+ user: users[1],
+ position: 2,
+ total: 2,
+ )
+ expect(confirmation_modal).to have_no_error_log_entries
+
+ confirmation_modal.close
+ deleted_ids = users[0..1].map(&:id)
+ expect(admin_users_page).to have_no_users(deleted_ids)
+ expect(User.where(id: deleted_ids).count).to eq(0)
+ end
+
+ it "remembers selected users when the user list refreshes due to search" do
+ admin_users_page.visit
+ admin_users_page.bulk_select_button.click
+ admin_users_page.search_input.fill_in(with: users[0].username)
+ admin_users_page.user_row(users[0].id).bulk_select_checkbox.click
+
+ admin_users_page.search_input.fill_in(with: users[1].username)
+ admin_users_page.user_row(users[1].id).bulk_select_checkbox.click
+
+ admin_users_page.search_input.fill_in(with: "")
+
+ expect(admin_users_page).to have_users(users.map(&:id))
+ expect(admin_users_page.user_row(users[0].id).bulk_select_checkbox).to be_checked
+ expect(admin_users_page.user_row(users[1].id).bulk_select_checkbox).to be_checked
+ expect(admin_users_page.user_row(users[2].id).bulk_select_checkbox).not_to be_checked
+
+ admin_users_page.bulk_actions_dropdown.expand
+ admin_users_page.bulk_actions_dropdown.option(".bulk-delete").click
+
+ expect(confirmation_modal).to be_open
+ confirmation_modal.fill_in_confirmation_phase(user_count: 2)
+ confirmation_modal.confirm_button.click
+ expect(confirmation_modal).to have_successful_log_entry_for_user(
+ user: users[0],
+ position: 1,
+ total: 2,
+ )
+ expect(confirmation_modal).to have_successful_log_entry_for_user(
+ user: users[1],
+ position: 2,
+ total: 2,
+ )
+ confirmation_modal.close
+
+ deleted_ids = users[0..1].map(&:id)
+ expect(admin_users_page).to have_no_users(deleted_ids)
+ expect(User.where(id: deleted_ids).count).to eq(0)
+ end
+
+ it "displays an error message if bulk delete fails" do
+ admin_users_page.visit
+ admin_users_page.bulk_select_button.click
+
+ admin_users_page.user_row(users[0].id).bulk_select_checkbox.click
+ admin_users_page.bulk_actions_dropdown.expand
+ admin_users_page.bulk_actions_dropdown.option(".bulk-delete").click
+ confirmation_modal.fill_in_confirmation_phase(user_count: 1)
+
+ users[0].update!(admin: true)
+
+ confirmation_modal.confirm_button.click
+ expect(confirmation_modal).to have_error_log_entry(
+ I18n.t("js.generic_error_with_reason", error: I18n.t("user.cannot_bulk_delete")),
+ )
+ confirmation_modal.close
+ expect(admin_users_page).to have_users([users[0].id])
+ end
end
end
diff --git a/spec/system/page_objects/modals/bulk_user_delete_confirmation.rb b/spec/system/page_objects/modals/bulk_user_delete_confirmation.rb
new file mode 100644
index 00000000000..f70b93b1a3f
--- /dev/null
+++ b/spec/system/page_objects/modals/bulk_user_delete_confirmation.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+module PageObjects
+ module Modals
+ class BulkUserDeleteConfirmation < Base
+ MODAL_SELECTOR = ".bulk-user-delete-confirmation"
+
+ def confirm_button
+ within(modal) { find(".btn.confirm-delete") }
+ end
+
+ def has_confirm_button_disabled?
+ within(modal) { has_css?(".btn.confirm-delete[disabled]") }
+ end
+
+ def has_confirm_button_enabled?
+ within(modal) do
+ has_no_css?(".btn.confirm-delete[disabled]") && has_css?(".btn.confirm-delete")
+ end
+ end
+
+ def fill_in_confirmation_phase(user_count:)
+ within(modal) do
+ find("input.confirmation-phrase").fill_in(
+ with:
+ I18n.t(
+ "admin_js.admin.users.bulk_actions.delete.confirmation_modal.confirmation_phrase",
+ count: user_count,
+ ),
+ )
+ end
+ end
+
+ def has_successful_log_entry_for_user?(user:, position:, total:)
+ within(modal) do
+ has_css?(
+ ".bulk-user-delete-confirmation__progress-line.-success",
+ text:
+ I18n.t(
+ "admin_js.admin.users.bulk_actions.delete.confirmation_modal.user_delete_succeeded",
+ position:,
+ total:,
+ username: user.username,
+ ),
+ )
+ end
+ end
+
+ def has_no_error_log_entries?
+ within(modal) { has_no_css?(".bulk-user-delete-confirmation__progress-line.-error") }
+ end
+
+ def has_error_log_entry?(message)
+ within(modal) do
+ has_css?(".bulk-user-delete-confirmation__progress-line.-error", text: message)
+ end
+ end
+
+ private
+
+ def modal
+ find(MODAL_SELECTOR)
+ end
+ end
+ end
+end