UX: Redesign and refactor penalty modals (#19458)

This merges the two modals code to remove duplication and implements
a more consistent design.
This commit is contained in:
Bianca Nenciu 2022-12-19 19:36:03 +02:00 committed by GitHub
parent 43a8ca00b9
commit 1ad06eb764
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 416 additions and 401 deletions

View File

@ -1,9 +1,9 @@
import Component from "@ember/component";
import { equal } from "@ember/object/computed";
import discourseComputed, {
afterRender,
} from "discourse-common/utils/decorators";
import Component from "@ember/component";
import I18n from "I18n";
import { equal } from "@ember/object/computed";
const ACTIONS = ["delete", "delete_replies", "edit", "none"];

View File

@ -1,8 +1,8 @@
import Component from "@ember/component";
import I18n from "I18n";
import { action } from "@ember/object";
import discourseComputed from "discourse-common/utils/decorators";
import { equal } from "@ember/object/computed";
import discourseComputed from "discourse-common/utils/decorators";
import I18n from "I18n";
const CUSTOM_REASON_KEY = "custom";

View File

@ -5,7 +5,7 @@ import discourseComputed from "discourse-common/utils/decorators";
export default Component.extend({
tagName: "",
@discourseComputed("type")
@discourseComputed("penaltyType")
penaltyField(penaltyType) {
if (penaltyType === "suspend") {
return "can_be_suspended";

View File

@ -0,0 +1,168 @@
import Controller from "@ember/controller";
import { action } from "@ember/object";
import { next } from "@ember/runloop";
import { inject as service } from "@ember/service";
import { isEmpty } from "@ember/utils";
import discourseComputed from "discourse-common/utils/decorators";
import { extractError } from "discourse/lib/ajax-error";
import ModalFunctionality from "discourse/mixins/modal-functionality";
import I18n from "I18n";
export default Controller.extend(ModalFunctionality, {
dialog: service(),
loadingUser: false,
errorMessage: null,
penaltyType: null,
penalizeUntil: null,
reason: null,
message: null,
postId: null,
postAction: null,
postEdit: null,
user: null,
otherUserIds: null,
loading: false,
confirmClose: false,
onShow() {
this.setProperties({
loadingUser: true,
errorMessage: null,
penaltyType: null,
penalizeUntil: null,
reason: null,
message: null,
postId: null,
postAction: "delete",
postEdit: null,
user: null,
otherUserIds: [],
loading: false,
errorMessage: null,
reason: null,
message: null,
confirmClose: false,
});
},
finishedSetup() {
this.set("penalizeUntil", this.user?.next_penalty);
},
beforeClose() {
if (this.confirmClose) {
return true;
}
if (
(this.reason && this.reason.length > 1) ||
(this.message && this.message.length > 1)
) {
this.send("hideModal");
this.dialog.confirm({
message: I18n.t("admin.user.confirm_cancel_penalty"),
didConfirm: () => {
next(() => {
this.set("confirmClose", true);
this.send("closeModal");
});
},
didCancel: () => this.send("reopenModal"),
});
return false;
}
},
@discourseComputed("penaltyType")
modalTitle(penaltyType) {
if (penaltyType === "suspend") {
return "admin.user.suspend_modal_title";
} else if (penaltyType === "silence") {
return "admin.user.silence_modal_title";
}
},
@discourseComputed("penaltyType")
buttonLabel(penaltyType) {
if (penaltyType === "suspend") {
return "admin.user.suspend";
} else if (penaltyType === "silence") {
return "admin.user.silence";
}
},
@discourseComputed(
"user.penalty_counts.suspended",
"user.penalty_counts.silenced"
)
penaltyHistory(suspendedCount, silencedCount) {
return I18n.messageFormat("admin.user.penalty_history_MF", {
SUSPENDED: suspendedCount,
SILENCED: silencedCount,
});
},
@discourseComputed("penaltyType", "user.canSuspend", "user.canSilence")
canPenalize(penaltyType, canSuspend, canSilence) {
if (penaltyType === "suspend") {
return canSuspend;
} else if (penaltyType === "silence") {
return canSilence;
}
return false;
},
@discourseComputed("penalizing", "penalizeUntil", "reason")
submitDisabled(penalizing, penalizeUntil, reason) {
return penalizing || isEmpty(penalizeUntil) || !reason || reason.length < 1;
},
@action
async penalizeUser() {
if (this.submitDisabled) {
return;
}
this.set("penalizing", true);
this.set("confirmClose", true);
if (this.before) {
this.before();
}
let result;
try {
const opts = {
reason: this.reason,
message: this.message,
post_id: this.postId,
post_action: this.postAction,
post_edit: this.postEdit,
other_user_ids: this.otherUserIds,
};
if (this.penaltyType === "suspend") {
opts.suspend_until = this.penalizeUntil;
result = await this.user.suspend(opts);
} else if (this.penaltyType === "silence") {
opts.silenced_till = this.penalizeUntil;
result = await this.user.silence(opts);
} else {
// eslint-disable-next-line no-console
console.error("Unknown penalty type:", this.penaltyType);
}
this.send("closeModal");
if (this.successCallback) {
await this.successCallback(result);
}
} catch {
this.set("errorMessage", extractError(result));
} finally {
this.set("penalizing", false);
}
},
});

View File

@ -1,48 +0,0 @@
import Controller from "@ember/controller";
import PenaltyController from "admin/mixins/penalty-controller";
import discourseComputed from "discourse-common/utils/decorators";
import { isEmpty } from "@ember/utils";
export default Controller.extend(PenaltyController, {
silenceUntil: null,
silencing: false,
onShow() {
this.resetModal();
this.setProperties({
silenceUntil: null,
silencing: false,
otherUserIds: [],
});
},
finishedSetup() {
this.set("silenceUntil", this.user?.next_penalty);
},
@discourseComputed("silenceUntil", "reason", "silencing")
submitDisabled(silenceUntil, reason, silencing) {
return silencing || isEmpty(silenceUntil) || !reason || reason.length < 1;
},
actions: {
silence() {
if (this.submitDisabled) {
return;
}
this.set("silencing", true);
this.penalize(() => {
return this.user.silence({
silenced_till: this.silenceUntil,
reason: this.reason,
message: this.message,
post_id: this.postId,
post_action: this.postAction,
post_edit: this.postEdit,
other_user_ids: this.otherUserIds,
});
}).finally(() => this.set("silencing", false));
},
},
});

View File

@ -1,48 +0,0 @@
import Controller from "@ember/controller";
import PenaltyController from "admin/mixins/penalty-controller";
import discourseComputed from "discourse-common/utils/decorators";
import { isEmpty } from "@ember/utils";
export default Controller.extend(PenaltyController, {
suspendUntil: null,
suspending: false,
onShow() {
this.resetModal();
this.setProperties({
suspendUntil: null,
suspending: false,
otherUserIds: [],
});
},
finishedSetup() {
this.set("suspendUntil", this.user?.next_penalty);
},
@discourseComputed("suspendUntil", "reason", "suspending")
submitDisabled(suspendUntil, reason, suspending) {
return suspending || isEmpty(suspendUntil) || !reason || reason.length < 1;
},
actions: {
suspend() {
if (this.submitDisabled) {
return;
}
this.set("suspending", true);
this.penalize(() => {
return this.user.suspend({
suspend_until: this.suspendUntil,
reason: this.reason,
message: this.message,
post_id: this.postId,
post_action: this.postAction,
post_edit: this.postEdit,
other_user_ids: this.otherUserIds,
});
}).finally(() => this.set("suspending", false));
},
},
});

View File

@ -1,76 +0,0 @@
import I18n from "I18n";
import Mixin from "@ember/object/mixin";
import ModalFunctionality from "discourse/mixins/modal-functionality";
import { Promise } from "rsvp";
import { extractError } from "discourse/lib/ajax-error";
import { next } from "@ember/runloop";
import { inject as service } from "@ember/service";
export default Mixin.create(ModalFunctionality, {
dialog: service(),
errorMessage: null,
reason: null,
message: null,
postEdit: null,
postAction: null,
user: null,
postId: null,
successCallback: null,
confirmClose: false,
resetModal() {
this.setProperties({
errorMessage: null,
reason: null,
message: null,
loadingUser: true,
postId: null,
postEdit: null,
postAction: "delete",
before: null,
successCallback: null,
confirmClose: false,
});
},
beforeClose() {
// prompt a confirmation if we have unsaved content
if (
!this.confirmClose &&
((this.reason && this.reason.length > 1) ||
(this.message && this.message.length > 1))
) {
this.send("hideModal");
this.dialog.confirm({
message: I18n.t("admin.user.confirm_cancel_penalty"),
didConfirm: () => {
next(() => {
this.set("confirmClose", true);
this.send("closeModal");
});
},
didCancel: () => this.send("reopenModal"),
});
return false;
}
},
penalize(cb) {
let before = this.before;
let promise = before ? before() : Promise.resolve();
return promise
.then(() => cb())
.then((result) => {
this.set("confirmClose", true);
this.send("closeModal");
let callback = this.successCallback;
if (callback) {
callback(result);
}
})
.catch((error) => {
this.set("errorMessage", extractError(error));
});
},
});

View File

@ -197,6 +197,7 @@ const AdminUser = User.extend({
canLockTrustLevel: lt("trust_level", 4),
canSuspend: not("staff"),
canSilence: not("staff"),
@discourseComputed("suspended_till", "suspended_at")
suspendDuration(suspendedTill, suspendedAt) {

View File

@ -41,11 +41,17 @@ export default Service.extend({
_showControlModal(type, user, opts) {
opts = opts || {};
let controller = showModal(`admin-${type}-user`, {
const controller = showModal(`admin-penalize-user`, {
admin: true,
modalClass: `${type}-user-modal`,
});
controller.setProperties({ postId: opts.postId, postEdit: opts.postEdit });
controller.setProperties({
penaltyType: type,
postId: opts.postId,
postEdit: opts.postEdit,
});
return (
user.adminUserView

View File

@ -0,0 +1,18 @@
<div class="penalty-reason-controls">
{{#if (eq @penaltyType "suspend")}}
<label class="suspend-reason-title">{{i18n "admin.user.suspend_reason_title"}}</label>
<ComboBox @content={{this.reasons}} @value={{this.selectedReason}} @class="suspend-reason" @onChange={{this.setSelectedReason}} />
{{#if this.isCustomReason}}
<TextField @value={{this.customReason}} @class="suspend-reason" @onChange={{this.setCustomReason}} />
{{/if}}
{{else if (eq @penaltyType "silence")}}
<label class="silence-reason-title">{{html-safe (i18n "admin.user.silence_reason_label")}}</label>
<TextField @value={{this.customReason}} @class="silence-reason" @onChange={{this.setCustomReason}} @placeholderKey="admin.user.silence_reason_placeholder" />
{{/if}}
</div>
<div class="penalty-message-controls">
<label>{{i18n "admin.user.suspend_message"}}</label>
<Textarea @value={{this.message}} class="suspend-message" placeholder={{i18n "admin.user.suspend_message_placeholder"}} />
</div>

View File

@ -1,6 +1,6 @@
<div class="penalty-similar-users">
<p class="alert alert-danger">
{{i18n "admin.user.other_matches" (hash count=this.user.similar_users_count username=this.user.username)}}
<p class="alert alert-warning">
{{html-safe (i18n "admin.user.other_matches" (hash count=this.user.similar_users_count username=this.user.username))}}
</p>
<table class="table">

View File

@ -1,4 +1,4 @@
<div class="reason-controls">
<div class="penalty-reason-controls">
<label>
<div class="silence-reason-label">
{{html-safe (i18n "admin.user.silence_reason_label")}}

View File

@ -1,25 +0,0 @@
<div class="reason-controls">
<label>
<div class="suspend-reason-label">
{{#if this.siteSettings.hide_suspension_reasons}}
{{html-safe (i18n "admin.user.suspend_reason_hidden_label")}}
{{else}}
{{html-safe (i18n "admin.user.suspend_reason_label")}}
{{/if}}
</div>
</label>
<label>
{{i18n "admin.user.suspend_reason_title"}}
</label>
<ComboBox @content={{this.reasons}} @value={{this.selectedReason}} @class="suspend-reason" @onChange={{action this.setSelectedReason}} />
{{#if this.isCustomReason}}
<TextField @value={{this.customReason}} @class="suspend-reason" @onChange={{action this.setCustomReason}} />
{{/if}}
</div>
<label>
<div class="suspend-message-label">
{{i18n "admin.user.suspend_message"}}
</div>
</label>
<Textarea @value={{this.message}} class="suspend-message" placeholder={{i18n "admin.user.suspend_message_placeholder"}} />

View File

@ -0,0 +1,63 @@
<DModalBody @title={{this.modalTitle}}>
<ConditionalLoadingSpinner @condition={{this.loadingUser}}>
{{#if this.errorMessage}}
<div class="alert alert-error">{{this.errorMessage}}</div>
{{/if}}
{{#if this.canPenalize}}
<div class="penalty-duration-controls">
{{#if (eq this.penaltyType "suspend")}}
<FutureDateInput @class="suspend-until"
@label="admin.user.suspend_duration"
@clearable={{false}}
@input={{this.penalizeUntil}}
@onChangeInput={{action (mut this.penalizeUntil)}} />
{{else if (eq this.penaltyType "silence")}}
<FutureDateInput @class="silence-until"
@label="admin.user.silence_duration"
@clearable={{false}}
@input={{this.penalizeUntil}}
@onChangeInput={{action (mut this.penalizeUntil)}} />
{{/if}}
</div>
{{#if (eq this.penaltyType "suspend")}}
<div class="penalty-reason-visibility">
{{#if this.siteSettings.hide_suspension_reasons}}
{{html-safe (i18n "admin.user.suspend_reason_hidden_label")}}
{{else}}
{{html-safe (i18n "admin.user.suspend_reason_label")}}
{{/if}}
</div>
{{/if}}
<AdminPenaltyReason @penaltyType={{this.penaltyType}} @reason={{this.reason}} @message={{this.message}} />
{{#if this.postId}}
<AdminPenaltyPostAction @postId={{this.postId}} @postAction={{this.postAction}} @postEdit={{this.postEdit}} />
{{/if}}
{{#if this.user.similar_users}}
<AdminPenaltySimilarUsers @penaltyType={{this.penaltyType}} @user={{this.user}} @selectedUserIds={{this.otherUserIds}} />
{{/if}}
<div class="penalty-history">{{html-safe this.penaltyHistory}}</div>
{{else}}
{{#if (eq this.penaltyType "suspend")}}
<div class="cant-suspend">{{i18n "admin.user.cant_suspend"}}</div>
{{else if (eq this.penaltyType "silence")}}
<div class="cant-silence">{{i18n "admin.user.cant_silence"}}</div>
{{/if}}
{{/if}}
</ConditionalLoadingSpinner>
</DModalBody>
<div class="modal-footer">
<DButton @class="btn-danger perform-penalize"
@action={{this.penalizeUser}}
@disabled={{this.submitDisabled}}
@icon="ban"
@label={{this.buttonLabel}} />
<DModalCancel @close={{route-action "closeModal"}} />
<ConditionalLoadingSpinner @condition={{this.loading}} @size="small" />
</div>

View File

@ -1,33 +0,0 @@
<DModalBody @title="admin.user.silence_modal_title">
<ConditionalLoadingSpinner @condition={{this.loadingUser}}>
{{#if this.errorMessage}}
<div class="alert alert-error">{{this.errorMessage}}</div>
{{/if}}
<AdminPenaltyHistory @user={{this.user}} />
<div class="until-controls">
<label>
<FutureDateInput @class="silence-until" @label="admin.user.silence_duration" @clearable={{false}} @input={{this.silenceUntil}} @onChangeInput={{action (mut this.silenceUntil)}} />
</label>
</div>
<SilenceDetails @reason={{this.reason}} @message={{this.message}} />
{{#if this.postId}}
<PenaltyPostAction @postId={{this.postId}} @postAction={{this.postAction}} @postEdit={{this.postEdit}} />
{{/if}}
{{#if this.user.similar_users}}
<AdminPenaltySimilarUsers @type="silence" @user={{this.user}} @selectedUserIds={{this.otherUserIds}} />
{{/if}}
</ConditionalLoadingSpinner>
</DModalBody>
<div class="modal-footer">
<DButton @class="btn-danger perform-silence" @action={{action "silence"}} @disabled={{this.submitDisabled}} @icon="microphone-slash" @label="admin.user.silence" />
<DModalCancel @close={{route-action "closeModal"}} />
<ConditionalLoadingSpinner @condition={{this.loading}} @size="small" />
</div>

View File

@ -1,39 +0,0 @@
<DModalBody @title="admin.user.suspend_modal_title">
<ConditionalLoadingSpinner @condition={{this.loadingUser}}>
{{#if this.errorMessage}}
<div class="alert alert-error">{{this.errorMessage}}</div>
{{/if}}
{{#if this.user.canSuspend}}
<AdminPenaltyHistory @user={{this.user}} />
<div class="until-controls">
<label>
<FutureDateInput @class="suspend-until" @label="admin.user.suspend_duration" @clearable={{false}} @input={{this.suspendUntil}} @onChangeInput={{action (mut this.suspendUntil)}} />
</label>
</div>
<SuspensionDetails @reason={{this.reason}} @message={{this.message}} />
{{#if this.postId}}
<PenaltyPostAction @postId={{this.postId}} @postAction={{this.postAction}} @postEdit={{this.postEdit}} />
{{/if}}
{{#if this.user.similar_users}}
<AdminPenaltySimilarUsers @type="suspend" @user={{this.user}} @selectedUserIds={{this.otherUserIds}} />
{{/if}}
{{else}}
<div class="cant-suspend">
{{i18n "admin.user.cant_suspend"}}
</div>
{{/if}}
</ConditionalLoadingSpinner>
</DModalBody>
<div class="modal-footer">
<DButton @class="btn-danger perform-suspend" @action={{action "suspend"}} @disabled={{this.submitDisabled}} @icon="ban" @label="admin.user.suspend" />
<DModalCancel @close={{route-action "closeModal"}} />
<ConditionalLoadingSpinner @condition={{this.loading}} @size="small" />
</div>

View File

@ -1,3 +1,4 @@
import { click, fillIn, visit } from "@ember/test-helpers";
import {
acceptance,
count,
@ -7,10 +8,9 @@ import {
query,
queryAll,
} from "discourse/tests/helpers/qunit-helpers";
import { click, fillIn, visit } from "@ember/test-helpers";
import selectKit from "discourse/tests/helpers/select-kit-helper";
import { test } from "qunit";
import I18n from "I18n";
import { test } from "qunit";
acceptance("Admin - Suspend User", function (needs) {
needs.user();
@ -83,7 +83,7 @@ acceptance("Admin - Suspend User", function (needs) {
await click(".suspend-user");
assert.strictEqual(
count(".perform-suspend[disabled]"),
count(".perform-penalize[disabled]"),
1,
"disabled by default"
);
@ -94,9 +94,9 @@ acceptance("Admin - Suspend User", function (needs) {
await fillIn("input.suspend-reason", "for breaking the rules");
await fillIn(".suspend-message", "this is an email reason why");
assert.ok(!exists(".perform-suspend[disabled]"), "no longer disabled");
assert.ok(!exists(".perform-penalize[disabled]"), "no longer disabled");
await click(".perform-suspend");
await click(".perform-penalize");
assert.ok(!exists(".suspend-user-modal:visible"));
assert.ok(exists(".suspension-info"));
@ -125,6 +125,48 @@ acceptance("Admin - Suspend User - timeframe choosing", function (needs) {
await click(".suspend-user");
await click(".future-date-input-selector-header");
const options = Array.from(
queryAll(`ul.select-kit-collection li span.name`)
).map((el) => el.innerText.trim());
const expected = [
I18n.t("time_shortcut.later_today"),
I18n.t("time_shortcut.tomorrow"),
I18n.t("time_shortcut.later_this_week"),
I18n.t("time_shortcut.start_of_next_business_week_alt"),
I18n.t("time_shortcut.two_weeks"),
I18n.t("time_shortcut.next_month"),
I18n.t("time_shortcut.two_months"),
I18n.t("time_shortcut.three_months"),
I18n.t("time_shortcut.four_months"),
I18n.t("time_shortcut.six_months"),
I18n.t("time_shortcut.one_year"),
I18n.t("time_shortcut.forever"),
I18n.t("time_shortcut.custom"),
];
assert.deepEqual(options, expected, "options are correct");
});
});
acceptance("Admin - Silence User", function (needs) {
let clock = null;
needs.user();
needs.hooks.beforeEach(() => {
const timezone = loggedInUser().user_option.timezone;
clock = fakeTime("2100-05-03T08:00:00", timezone, true); // Monday morning
});
needs.hooks.afterEach(() => {
clock.restore();
});
test("shows correct timeframe options", async function (assert) {
await visit("/admin/users/1234/regular");
await click(".silence-user");
await click(".future-date-input-selector-header");
const options = Array.from(
queryAll(`ul.select-kit-collection li span.name`).map((_, x) =>
x.innerText.trim()

View File

@ -1,53 +0,0 @@
import {
acceptance,
fakeTime,
loggedInUser,
queryAll,
} from "discourse/tests/helpers/qunit-helpers";
import { click, visit } from "@ember/test-helpers";
import { test } from "qunit";
import I18n from "I18n";
acceptance("Admin - Silence User", function (needs) {
let clock = null;
needs.user();
needs.hooks.beforeEach(() => {
const timezone = loggedInUser().user_option.timezone;
clock = fakeTime("2100-05-03T08:00:00", timezone, true); // Monday morning
});
needs.hooks.afterEach(() => {
clock.restore();
});
test("shows correct timeframe options", async function (assert) {
await visit("/admin/users/1234/regular");
await click(".silence-user");
await click(".future-date-input-selector-header");
const options = Array.from(
queryAll(`ul.select-kit-collection li span.name`).map((_, x) =>
x.innerText.trim()
)
);
const expected = [
I18n.t("time_shortcut.later_today"),
I18n.t("time_shortcut.tomorrow"),
I18n.t("time_shortcut.later_this_week"),
I18n.t("time_shortcut.start_of_next_business_week_alt"),
I18n.t("time_shortcut.two_weeks"),
I18n.t("time_shortcut.next_month"),
I18n.t("time_shortcut.two_months"),
I18n.t("time_shortcut.three_months"),
I18n.t("time_shortcut.four_months"),
I18n.t("time_shortcut.six_months"),
I18n.t("time_shortcut.one_year"),
I18n.t("time_shortcut.forever"),
I18n.t("time_shortcut.custom"),
];
assert.deepEqual(options, expected, "options are correct");
});
});

View File

@ -125,7 +125,7 @@ acceptance("flagging", function (needs) {
assert.ok(exists(".modal-body"));
await fillIn(".silence-reason", "for breaking the rules");
await click(".perform-silence");
await click(".perform-penalize");
assert.ok(!exists(".modal-body"));
});

View File

@ -1010,7 +1010,6 @@ a.inline-editable-field {
@import "common/admin/settings";
@import "common/admin/users";
@import "common/admin/penalty";
@import "common/admin/suspend";
@import "common/admin/badges";
@import "common/admin/emails";
@import "common/admin/json_schema_editor";

View File

@ -1,11 +1,90 @@
.silence-user-modal,
.suspend-user-modal {
.table {
width: 100%;
.penalty-duration,
.penalty-suspend-forever,
.suspend-reason-title,
.penalty-reason-controls label,
.penalty-message-controls label,
.penalty-post-controls label {
font-weight: bold;
}
th,
td {
padding: 8px 0px;
.penalty-reason-visibility,
.penalty-reason-controls,
.penalty-similar-users {
margin-bottom: 0.5em;
}
.penalty-duration-controls,
.penalty-reason-visibility,
.penalty-post-controls {
margin-bottom: 1em;
}
.penalty-duration-controls {
.future-date-input {
align-items: end;
display: flex;
justify-content: space-between;
input {
height: 34px;
}
.control-group {
margin-bottom: 0;
&:first-child {
width: 100%;
label {
font-weight: bold;
}
details {
width: 100%;
}
}
}
.future-date-input-date-picker,
.future-date-input-time-picker {
align-items: center;
display: flex;
margin-left: 0.5em;
}
}
}
.penalty-reason-controls {
input,
.combo-box {
width: 100%;
margin-bottom: 0.5em;
}
}
.penalty-post-controls {
.select-kit {
width: 100%;
}
}
.penalty-similar-users {
background-color: var(--primary-very-low);
padding: 0.5em;
.alert {
margin: 0 0 0.5em 0;
}
.table {
width: 100%;
th,
td {
padding: 8px 0px;
}
}
}
}

View File

@ -1,52 +0,0 @@
.suspend-user-modal {
.until-controls {
margin-bottom: 1em;
}
.suspend-reason {
width: 100%;
&.combo-box {
margin-bottom: 9px;
}
}
.suspend-reason-label,
.suspend-message-label {
margin-bottom: 0.5em;
}
.inline-spinner {
float: right;
}
}
.modal-body {
.penalty-post-edit {
margin-top: 1em;
textarea {
height: 10em;
}
}
.penalty-history {
margin-bottom: 1em;
padding-bottom: 0.5em;
border-bottom: 1px solid var(--primary-low);
display: flex;
& > * {
flex-basis: 100%;
text-align: center;
padding: 1em 0;
font-weight: 600;
label {
font-weight: 600;
justify-content: center;
}
}
.danger {
background-color: var(--danger);
color: var(--secondary);
}
}
}

View File

@ -161,7 +161,6 @@ class AdminDetailedUserSerializer < AdminUserSerializer
def similar_users
ActiveModel::ArraySerializer.new(
@options[:similar_users],
each_serializer: AdminUserListSerializer,
each_serializer: SimilarAdminUserSerializer,
scope: scope,
root: false,

View File

@ -5516,7 +5516,7 @@ en:
user:
suspend_failed: "Something went wrong suspending this user %{error}"
unsuspend_failed: "Something went wrong unsuspending this user %{error}"
suspend_duration: "How long will the user be suspended for?"
suspend_duration: "Suspend user until:"
suspend_reason_label: "Why are you suspending? This text <b>will be visible to everyone</b> on this user's profile page, and will be shown to the user when they try to log in. Keep it short."
suspend_reason_hidden_label: "Why are you suspending? This text will be shown to the user when they try to log in. Keep it short."
suspend_reason: "Reason"
@ -5540,7 +5540,9 @@ en:
silence_message: "Email Message"
silence_message_placeholder: "(leave blank to send default message)"
suspended_until: "(until %{until})"
suspend_forever: "Suspend forever"
cant_suspend: "This user cannot be suspended."
cant_silence: "This user cannot be silenced."
delete_posts_failed: "There was a problem deleting the posts."
post_edits: "Post Edits"
@ -5551,7 +5553,19 @@ en:
penalty_post_edit: "Edit the post"
penalty_post_none: "Do nothing"
penalty_count: "Penalty Count"
penalty_history: "Penalty History"
# This string uses the ICU Message Format. See https://meta.discourse.org/t/7035 for translation guidelines.
penalty_history_MF: >-
In the past 6 months this user has been <b>suspended
{ SUSPENDED, plural,
one {# time}
other {# times}
}</b> and <b>silenced
{ SILENCED, plural,
one {# time}
other {# times}
}</b>.
clear_penalty_history:
title: "Clear Penalty History"
description: "users with penalties cannot reach TL3"
@ -5721,8 +5735,8 @@ en:
suspended_count: "Suspended"
last_six_months: "Last 6 months"
other_matches:
one: "There is %{count} other user with the same IP address. Review and select the suspicious ones to suspend along with %{username}."
other: "There are %{count} other users with the same IP address. Review and select the suspicious ones to suspend along with %{username}."
one: "There is <b>%{count} other user</b> with the same IP address. Review and select the suspicious ones to penalize along with %{username}."
other: "There are <b>%{count} other users</b> with the same IP address. Review and select the suspicious ones to penalize along with %{username}."
other_matches_list:
username: "Username"
trust_level: "Trust Level"