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
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"));
});