progress tracking and disable checkboxes for undeletable users

This commit is contained in:
OsamaSayegh 2024-11-20 06:14:07 +03:00
parent 372b2c6d7c
commit 728fcab49d
No known key found for this signature in database
GPG Key ID: 060E5AC82223685F
13 changed files with 554 additions and 114 deletions

View File

@ -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();
}
}
<template>
<DModal
class="bulk-user-delete-confirmation"
@closeModal={{this.closeModal}}
@title={{i18n
"admin.users.bulk_actions.delete.confirmation_modal.title"
count=@model.userIds.length
}}
>
<:body>
{{#if this.deleteStarted}}
<div class="bulk-user-delete-confirmation__progress">
{{#each this.logs as |entry|}}
<div
class="bulk-user-delete-confirmation__progress-line -{{entry.type}}"
>
{{entry.line}}
</div>
{{/each}}
<div class="bulk-user-delete-confirmation__progress-anchor">
</div>
</div>
{{else}}
<p>{{i18n
"admin.users.bulk_actions.delete.confirmation_modal.prompt_text"
count=@model.userIds.length
confirmation_phrase=this.confirmDeletePhrase
}}
</p>
<input
class="confirmation-phrase"
type="text"
placeholder={{this.confirmDeletePhrase}}
{{on "input" this.onPromptInput}}
/>
{{/if}}
</:body>
<:footer>
<DButton
class="confirm-delete btn-danger"
@icon="trash-can"
@label="admin.users.bulk_actions.delete.confirmation_modal.confirm"
@disabled={{this.confirmButtonDisabled}}
@action={{this.startDelete}}
/>
<DButton
class="btn-default"
@label="admin.users.bulk_actions.delete.confirmation_modal.close"
@action={{this.closeModal}}
/>
</:footer>
</DModal>
</template>
}

View File

@ -2,25 +2,25 @@ import { tracked } from "@glimmer/tracking";
import Controller from "@ember/controller"; import Controller from "@ember/controller";
import { action } from "@ember/object"; import { action } from "@ember/object";
import { service } from "@ember/service"; import { service } from "@ember/service";
import { observes } from "@ember-decorators/object";
import { computedI18n } from "discourse/lib/computed"; 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 CanCheckEmails from "discourse/mixins/can-check-emails";
import { INPUT_DELAY } from "discourse-common/config/environment"; import { INPUT_DELAY } from "discourse-common/config/environment";
import discourseDebounce from "discourse-common/lib/debounce"; 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 { i18n } from "discourse-i18n";
import BulkUserDeleteConfirmation from "admin/components/bulk-user-delete-confirmation";
import AdminUser from "admin/models/admin-user"; import AdminUser from "admin/models/admin-user";
export default class AdminUsersListShowController extends Controller.extend( export default class AdminUsersListShowController extends Controller.extend(
CanCheckEmails CanCheckEmails
) { ) {
@service dialog; @service dialog;
@service modal;
@tracked bulkSelect = false; @tracked bulkSelect = false;
@tracked displayBulkActions = false; @tracked displayBulkActions = false;
@tracked bulkSelectedUsers = null; @tracked bulkSelectedUserIdsSet = new Set();
@tracked bulkSelectedUsersMap = {};
query = null; query = null;
order = null; order = null;
@ -56,11 +56,6 @@ export default class AdminUsersListShowController extends Controller.extend(
return colCount; return colCount;
} }
@observes("listFilter")
_filterUsers() {
discourseDebounce(this, this.resetFilters, INPUT_DELAY);
}
resetFilters() { resetFilters() {
this._page = 1; this._page = 1;
this._results = []; 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 @action
loadMore() { loadMore() {
this._page += 1; this._page += 1;
@ -120,48 +121,36 @@ export default class AdminUsersListShowController extends Controller.extend(
toggleBulkSelect() { toggleBulkSelect() {
this.bulkSelect = !this.bulkSelect; this.bulkSelect = !this.bulkSelect;
this.displayBulkActions = false; this.displayBulkActions = false;
this.bulkSelectedUsers = null; this.bulkSelectedUsersMap = {};
this.bulkSelectedUserIdsSet = new Set();
} }
@action @action
bulkSelectItemToggle(userId, event) { bulkSelectItemToggle(userId, event) {
if (!this.bulkSelectedUsers) { if (event.target.checked) {
this.bulkSelectedUsers = {}; this.bulkSelectedUserIdsSet.add(userId);
this.bulkSelectedUsersMap[userId] = 1;
} else {
this.bulkSelectedUserIdsSet.delete(userId);
delete this.bulkSelectedUsersMap[userId];
}
this.displayBulkActions = this.bulkSelectedUserIdsSet.size > 0;
} }
if (event.target.checked) { @bind
this.bulkSelectedUsers[userId] = 1; async afterBulkDelete() {
} else { await this.resetFilters();
delete this.bulkSelectedUsers[userId]; this.bulkSelectedUsersMap = {};
} this.bulkSelectedUserIdsSet = new Set();
this.displayBulkActions = Object.keys(this.bulkSelectedUsers).length > 0; this.displayBulkActions = false;
} }
@action @action
performBulkDelete() { openBulkDeleteConfirmation() {
const userIds = Object.keys(this.bulkSelectedUsers); this.modal.show(BulkUserDeleteConfirmation, {
const count = userIds.length; model: {
this.dialog.deleteConfirm({ userIds: Array.from(this.bulkSelectedUserIdsSet),
title: I18n.t("admin.users.bulk_actions.confirm_delete_title", { afterBulkDelete: this.afterBulkDelete,
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));
}
}, },
}); });
} }

View File

@ -24,7 +24,8 @@ export default class AdminUsersListShowRoute extends DiscourseRoute {
listFilter: transition.to.queryParams.username, listFilter: transition.to.queryParams.username,
query: params.filter, query: params.filter,
refreshing: false, refreshing: false,
bulkSelectedUsers: null, bulkSelectedUsersMap: {},
bulkSelectedUserIdsSet: new Set(),
displayBulkActions: false, displayBulkActions: false,
}); });

View File

@ -21,10 +21,12 @@
<div class="admin-users-list__controls"> <div class="admin-users-list__controls">
<div class="username"> <div class="username">
<TextField <input
@value={{this.listFilter}} type="text"
@placeholder={{this.searchHint}} dir="auto"
@title={{this.searchHint}} placeholder={{this.searchHint}}
title={{this.searchHint}}
{{on "input" this.onListFilterChange}}
/> />
</div> </div>
{{#if this.displayBulkActions}} {{#if this.displayBulkActions}}
@ -41,9 +43,11 @@
<DropdownMenu as |dropdown|> <DropdownMenu as |dropdown|>
<dropdown.item> <dropdown.item>
<DButton <DButton
@translatedLabel={{i18n "admin.users.bulk_actions.delete"}} @translatedLabel={{i18n
"admin.users.bulk_actions.delete.label"
}}
@icon="trash-can" @icon="trash-can"
@action={{this.performBulkDelete}} @action={{this.openBulkDeleteConfirmation}}
class="bulk-delete btn-danger" class="bulk-delete btn-danger"
/> />
</dropdown.item> </dropdown.item>
@ -173,12 +177,38 @@
> >
<div class="directory-table__cell username"> <div class="directory-table__cell username">
{{#if this.bulkSelect}} {{#if this.bulkSelect}}
{{#if user.can_be_deleted}}
<input <input
type="checkbox" type="checkbox"
class="directory-table__cell-bulk-select" class="directory-table__cell-bulk-select"
checked={{eq (get this.bulkSelectedUsers user.id) 1}} checked={{eq (get this.bulkSelectedUsersMap user.id) 1}}
{{on "click" (fn this.bulkSelectItemToggle user.id)}} {{on "click" (fn this.bulkSelectItemToggle user.id)}}
/> />
{{else}}
<DTooltip
@identifier="bulk-delete-unavailable-reason"
@placement="bottom-start"
>
<:trigger>
<input
type="checkbox"
class="directory-table__cell-bulk-select"
disabled={{true}}
/>
</: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}}
</:content>
</DTooltip>
{{/if}}
{{/if}} {{/if}}
<a <a
class="avatar" class="avatar"
@ -325,9 +355,8 @@
{{/each}} {{/each}}
</:body> </:body>
</ResponsiveTable> </ResponsiveTable>
{{else if (not this.refreshing)}}
<ConditionalLoadingSpinner @condition={{this.refreshing}} />
{{else}}
<p>{{i18n "search.no_results"}}</p> <p>{{i18n "search.no_results"}}</p>
{{/if}} {{/if}}
<ConditionalLoadingSpinner @condition={{this.refreshing}} />
</LoadMore> </LoadMore>

View File

@ -1105,3 +1105,4 @@ a.inline-editable-field {
@import "common/admin/mini_profiler"; @import "common/admin/mini_profiler";
@import "common/admin/schema_theme_setting_editor"; @import "common/admin/schema_theme_setting_editor";
@import "common/admin/customize_themes_show_schema"; @import "common/admin/customize_themes_show_schema";
@import "common/admin/admin_bulk_users_delete_modal";

View File

@ -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;
}
}

View File

@ -414,6 +414,8 @@ class Admin::UsersController < Admin::StaffController
on_failed_policy(:can_delete_users) do on_failed_policy(:can_delete_users) do
render json: failed_json.merge(errors: [I18n.t("user.cannot_bulk_delete")]), status: 403 render json: failed_json.merge(errors: [I18n.t("user.cannot_bulk_delete")]), status: 403
end end
on_model_not_found(:users) { render json: failed_json, status: 404 }
end end
end end
end end

View File

@ -24,7 +24,8 @@ class AdminUserListSerializer < BasicUserSerializer
:silenced_till, :silenced_till,
:time_read, :time_read,
:staged, :staged,
:second_factor_enabled :second_factor_enabled,
:can_be_deleted
%i[days_visited posts_read_count topics_entered post_count].each do |sym| %i[days_visited posts_read_count topics_entered post_count].each do |sym|
attributes sym attributes sym
@ -111,4 +112,8 @@ class AdminUserListSerializer < BasicUserSerializer
def second_factor_enabled def second_factor_enabled
true true
end end
def can_be_deleted
scope.can_delete_user?(object)
end
end end

View File

@ -16,7 +16,11 @@ class User::BulkDestroy
private private
def fetch_users(params:) 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 end
def can_delete_users(guardian:, users:) def can_delete_users(guardian:, users:)
@ -24,12 +28,42 @@ class User::BulkDestroy
end end
def delete(users:, guardian:) def delete(users:, guardian:)
users.each do |u| users.each.with_index do |u, index|
position = index + 1
success =
UserDestroyer.new(guardian.user).destroy( UserDestroyer.new(guardian.user).destroy(
u, u,
delete_posts: true, delete_posts: true,
prepare_for_destroy: true,
context: I18n.t("staff_action_logs.bulk_user_delete", users: users.map(&:id).inspect), 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
end end
def publish_progress(actor, data)
::MessageBus.publish("/bulk-user-delete", data, user_ids: [actor.id])
end
end end

View File

@ -6641,13 +6641,27 @@ en:
hide_emails: "Hide Emails" hide_emails: "Hide Emails"
bulk_actions: bulk_actions:
title: "Bulk actions" title: "Bulk actions"
delete: "Delete users…" admin_cant_be_deleted: "This user can't be deleted because they're an admin"
confirm_delete_title: too_many_or_old_posts: "This user can't be deleted they have too many posts or a very old post"
one: "Delete %{count} user?" delete:
other: "Delete %{count} users?" label: "Delete users…"
confirm_delete_body: confirmation_modal:
one: "Are you sure you want to delete %{count} user? This action is permanent and cannot be reversed." prompt_text:
other: "Are you sure you want to delete %{count} users? This action is permanent and cannot be reversed." 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: nav:
new: "New" new: "New"
active: "Active" active: "Active"

View File

@ -1445,6 +1445,12 @@ RSpec.describe Admin::UsersController do
expect(User.where(id: deleted_users.map(&:id)).count).to eq(0) expect(User.where(id: deleted_users.map(&:id)).count).to eq(0)
end 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 it "doesn't allow deleting a user that can't be deleted" do
deleted_users[0].update!(admin: true) deleted_users[0].update!(admin: true)

View File

@ -2,13 +2,31 @@
describe "Admin Users Page", type: :system do describe "Admin Users Page", type: :system do
fab!(:current_user) { Fabricate(:admin) } fab!(:current_user) { Fabricate(:admin) }
fab!(:another_admin) { Fabricate(:admin) }
fab!(:users) { Fabricate.times(3, :user) } fab!(:users) { Fabricate.times(3, :user) }
let(:admin_users_page) { PageObjects::Pages::AdminUsers.new } let(:admin_users_page) { PageObjects::Pages::AdminUsers.new }
let(:dialog) { PageObjects::Components::Dialog.new }
before { sign_in(current_user) } before { sign_in(current_user) }
describe "bulk user delete" do
let(:confirmation_modal) { PageObjects::Modals::BulkUserDeleteConfirmation.new }
it "disables checkboxes for users that can't be deleted" do
admin_users_page.visit
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)
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
it "has a button that toggles the bulk select checkboxes" do it "has a button that toggles the bulk select checkboxes" do
admin_users_page.visit admin_users_page.visit
@ -34,9 +52,30 @@ describe "Admin Users Page", type: :system do
admin_users_page.bulk_actions_dropdown.expand admin_users_page.bulk_actions_dropdown.expand
admin_users_page.bulk_actions_dropdown.option(".bulk-delete").click admin_users_page.bulk_actions_dropdown.option(".bulk-delete").click
expect(dialog).to be_open expect(confirmation_modal).to be_open
dialog.click_danger expect(confirmation_modal).to have_confirm_button_disabled
confirmation_modal.fill_in_confirmation_phase(user_count: 3)
expect(confirmation_modal).to have_confirm_button_disabled
confirmation_modal.fill_in_confirmation_phase(user_count: 2)
expect(confirmation_modal).to have_confirm_button_enabled
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) deleted_ids = users[0..1].map(&:id)
expect(admin_users_page).to have_no_users(deleted_ids) expect(admin_users_page).to have_no_users(deleted_ids)
expect(User.where(id: deleted_ids).count).to eq(0) expect(User.where(id: deleted_ids).count).to eq(0)
@ -61,11 +100,43 @@ describe "Admin Users Page", type: :system do
admin_users_page.bulk_actions_dropdown.expand admin_users_page.bulk_actions_dropdown.expand
admin_users_page.bulk_actions_dropdown.option(".bulk-delete").click admin_users_page.bulk_actions_dropdown.option(".bulk-delete").click
expect(dialog).to be_open expect(confirmation_modal).to be_open
dialog.click_danger 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) deleted_ids = users[0..1].map(&:id)
expect(admin_users_page).to have_no_users(deleted_ids) expect(admin_users_page).to have_no_users(deleted_ids)
expect(User.where(id: deleted_ids).count).to eq(0) expect(User.where(id: deleted_ids).count).to eq(0)
end 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 end

View File

@ -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