DEV: upgrade grant badge modal to glimmer (#23526)

* DEV: upgrade grant badge modal to glimmer
* DEV: add unit tests for grant badge utils
* DEV: replace grant-badge-controller mixin with grant-badge-utils in admin-user-badges controller
* DEV: remove GrantBadgeController mixin
This commit is contained in:
Kelv 2023-09-14 10:05:29 +08:00 committed by GitHub
parent 7d4c47195a
commit a4238a3726
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 197 additions and 204 deletions

View File

@ -1,16 +1,15 @@
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import { alias, sort } from "@ember/object/computed";
import { alias, empty, sort } from "@ember/object/computed";
import Controller, { inject as controller } from "@ember/controller";
import GrantBadgeController from "discourse/mixins/grant-badge-controller";
import UserBadge from "discourse/models/user-badge";
import { grantableBadges } from "discourse/lib/grant-badge-utils";
import I18n from "I18n";
import discourseComputed from "discourse-common/utils/decorators";
import { next } from "@ember/runloop";
import { popupAjaxError } from "discourse/lib/ajax-error";
export default class AdminUserBadgesController extends Controller.extend(
GrantBadgeController
) {
export default class AdminUserBadgesController extends Controller {
@service dialog;
@controller adminUser;
@ -18,9 +17,14 @@ export default class AdminUserBadgesController extends Controller.extend(
@alias("model") userBadges;
@alias("badges") allBadges;
@sort("model", "badgeSortOrder") sortedBadges;
@empty("availableBadges") noAvailableBadges;
badgeSortOrder = ["granted_at:desc"];
@discourseComputed("allBadges.[]", "userBadges.[]")
availableBadges() {
return grantableBadges(this.get("allBadges"), this.get("userBadges"));
}
@discourseComputed("model", "model.[]", "model.expandedBadges.[]")
groupedBadges() {
const allBadges = this.model;
@ -60,7 +64,6 @@ export default class AdminUserBadgesController extends Controller.extend(
return expanded.sortBy("granted_at").reverse();
}
@action
expandGroup(userBadge) {
const model = this.model;
@ -70,16 +73,12 @@ export default class AdminUserBadgesController extends Controller.extend(
@action
performGrantBadge() {
this.grantBadge(
this.selectedBadgeId,
this.get("user.username"),
this.badgeReason
).then(
() => {
this.set("badgeReason", "");
UserBadge.grant(this.selectedBadgeId, this.get("user.username")).then(
(newBadge) => {
this.userBadges.pushObject(newBadge);
next(() => {
// Update the selected badge ID after the combobox has re-rendered.
const newSelectedBadge = this.grantableBadges[0];
const newSelectedBadge = this.availableBadges[0];
if (newSelectedBadge) {
this.set("selectedBadgeId", newSelectedBadge.get("id"));
}

View File

@ -14,7 +14,7 @@ export default class AdminUserBadgesRoute extends DiscourseRoute {
Badge.findAll().then(function (badges) {
controller.set("badges", badges);
if (badges.length > 0) {
let grantableBadges = controller.get("grantableBadges");
let grantableBadges = controller.get("availableBadges");
if (grantableBadges.length > 0) {
controller.set("selectedBadgeId", grantableBadges[0].get("id"));
}

View File

@ -13,7 +13,7 @@
<div class="admin-container user-badges">
<h2>{{i18n "admin.badges.grant_badge"}}</h2>
<br />
{{#if this.noGrantableBadges}}
{{#if this.noAvailableBadges}}
<p>{{i18n "admin.badges.no_badges"}}</p>
{{else}}
<form class="form-horizontal">
@ -21,7 +21,7 @@
<label>{{i18n "admin.badges.badge"}}</label>
<ComboBox
@value={{this.selectedBadgeId}}
@content={{this.grantableBadges}}
@content={{this.availableBadges}}
@onChange={{action (mut this.selectedBadgeId)}}
@options={{hash filterable=true}}
/>

View File

@ -9,13 +9,13 @@
>
<:body>
<ConditionalLoadingSpinner @condition={{this.loading}}>
{{#if this.noGrantableBadges}}
{{#if this.noAvailableBadges}}
<p>{{i18n "admin.badges.no_badges"}}</p>
{{else}}
<p>
<ComboBox
@value={{this.selectedBadgeId}}
@content={{this.grantableBadges}}
@content={{this.availableBadges}}
@onChange={{action (mut this.selectedBadgeId)}}
@options={{hash filterable=true none="badges.none"}}
/>

View File

@ -1,80 +1,82 @@
import { action } from "@ember/object";
import Component from "@ember/component";
import { tracked } from "@glimmer/tracking";
import Component from "@glimmer/component";
import Badge from "discourse/models/badge";
import GrantBadgeController from "discourse/mixins/grant-badge-controller";
import I18n from "I18n";
import UserBadge from "discourse/models/user-badge";
import discourseComputed from "discourse-common/utils/decorators";
import { extractError } from "discourse/lib/ajax-error";
import getURL from "discourse-common/lib/get-url";
import {
grantableBadges,
isBadgeGrantable,
} from "discourse/lib/grant-badge-utils";
export default class GrantBadgeModal extends Component.extend(
GrantBadgeController
) {
loading = true;
saving = false;
selectedBadgeId = null;
flash = null;
flashType = null;
allBadges = [];
userBadges = [];
export default class GrantBadgeModal extends Component {
@tracked loading = true;
@tracked saving = false;
@tracked selectedBadgeId = null;
@tracked flash = null;
@tracked flashType = null;
@tracked allBadges = [];
@tracked userBadges = [];
@tracked availableBadges = [];
@discourseComputed("model.selectedPost")
post() {
return this.get("model.selectedPost");
get noAvailableBadges() {
!this.availableBadges.length;
}
@discourseComputed("saving", "selectedBadgeGrantable")
buttonDisabled(saving, selectedBadgeGrantable) {
return saving || !selectedBadgeGrantable;
get post() {
return this.args.model.selectedPost;
}
get buttonDisabled() {
return (
this.saving ||
!isBadgeGrantable(this.selectedBadgeId, this.availableBadges)
);
}
#updateAvailableBadges() {
this.availableBadges = grantableBadges(this.allBadges, this.userBadges);
}
@action
async loadBadges() {
this.set("loading", true);
this.loading = true;
try {
const allBadges = await Badge.findAll();
const userBadges = await UserBadge.findByUsername(
this.get("post.username")
);
this.setProperties({
allBadges,
userBadges,
});
this.allBadges = await Badge.findAll();
this.userBadges = await UserBadge.findByUsername(this.post.username);
this.#updateAvailableBadges();
} catch (e) {
this.setProperties({
flash: extractError(e),
flashType: "error",
});
this.flash = extractError(e);
this.flashType = "error";
} finally {
this.set("loading", false);
this.loading = false;
}
}
@action
async performGrantBadge() {
try {
this.set("saving", true);
const username = this.get("post.username");
const newBadge = await this.grantBadge(
this.saving = true;
const username = this.post.username;
const newBadge = await UserBadge.grant(
this.selectedBadgeId,
username,
getURL(this.get("post.url"))
getURL(this.post.url)
);
this.set("selectedBadgeId", null);
this.setProperties({
flash: I18n.t("badges.successfully_granted", {
username,
badge: newBadge.get("badge.name"),
}),
flashType: "success",
this.userBadges.pushObject(newBadge);
this.#updateAvailableBadges();
this.selectedBadgeId = null;
this.flash = I18n.t("badges.successfully_granted", {
username,
badge: newBadge.get("badge.name"),
});
this.flashType = "success";
} catch (e) {
this.setProperties({
flash: extractError(e),
flashType: "error",
});
this.flash = extractError(e);
this.flashType = "error";
} finally {
this.set("saving", false);
this.saving = false;
}
}
}

View File

@ -0,0 +1,30 @@
import { convertIconClass } from "discourse-common/lib/icon-library";
export function grantableBadges(allBadges, userBadges) {
const granted = userBadges.reduce((map, badge) => {
map[badge.get("badge_id")] = true;
return map;
}, {});
return allBadges
.filter((badge) => {
return (
badge.get("enabled") &&
badge.get("manually_grantable") &&
(!granted[badge.get("id")] || badge.get("multiple_grant"))
);
})
.map((badge) => {
if (badge.get("icon")) {
badge.set("icon", convertIconClass(badge.icon));
}
return badge;
})
.sort((a, b) => a.get("name").localeCompare(b.get("name")));
}
export function isBadgeGrantable(badgeId, availableBadges) {
return (
availableBadges && availableBadges.some((b) => b.get("id") === badgeId)
);
}

View File

@ -1,53 +0,0 @@
import Mixin from "@ember/object/mixin";
import UserBadge from "discourse/models/user-badge";
import { convertIconClass } from "discourse-common/lib/icon-library";
import discourseComputed from "discourse-common/utils/decorators";
import { empty } from "@ember/object/computed";
export default Mixin.create({
@discourseComputed("allBadges.[]", "userBadges.[]")
grantableBadges(allBadges, userBadges) {
const granted = userBadges.reduce((map, badge) => {
map[badge.get("badge_id")] = true;
return map;
}, {});
return allBadges
.filter((badge) => {
return (
badge.get("enabled") &&
badge.get("manually_grantable") &&
(!granted[badge.get("id")] || badge.get("multiple_grant"))
);
})
.map((badge) => {
if (badge.get("icon")) {
badge.set("icon", convertIconClass(badge.icon));
}
return badge;
})
.sort((a, b) => a.get("name").localeCompare(b.get("name")));
},
noGrantableBadges: empty("grantableBadges"),
@discourseComputed("selectedBadgeId", "grantableBadges")
selectedBadgeGrantable(selectedBadgeId, grantableBadges) {
return (
grantableBadges &&
grantableBadges.find((badge) => badge.get("id") === selectedBadgeId)
);
},
grantBadge(selectedBadgeId, username, badgeReason) {
return UserBadge.grant(selectedBadgeId, username, badgeReason).then(
(newBadge) => {
this.userBadges.pushObject(newBadge);
return newBadge;
},
(error) => {
throw error;
}
);
},
});

View File

@ -5,7 +5,7 @@ import Badge from "discourse/models/badge";
module("Unit | Controller | admin-user-badges", function (hooks) {
setupTest(hooks);
test("grantableBadges", function (assert) {
test("availableBadges", function (assert) {
const badgeFirst = Badge.create({
id: 3,
name: "A Badge",
@ -50,7 +50,7 @@ module("Unit | Controller | admin-user-badges", function (hooks) {
});
const sortedNames = [badgeFirst.name, badgeMiddle.name, badgeLast.name];
const badgeNames = controller.grantableBadges.map((badge) => badge.name);
const badgeNames = controller.availableBadges.map((badge) => badge.name);
assert.notOk(
badgeNames.includes(badgeDisabled),

View File

@ -0,0 +1,96 @@
import { module, test } from "qunit";
import Badge from "discourse/models/badge";
import {
grantableBadges,
isBadgeGrantable,
} from "discourse/lib/grant-badge-utils";
module("Unit | Utility | Grant Badge", function (hooks) {
hooks.beforeEach(() => {
const firstBadge = Badge.create({
id: 3,
name: "A Badge",
enabled: true,
manually_grantable: true,
});
const middleBadge = Badge.create({
id: 1,
name: "My Badge",
enabled: true,
manually_grantable: true,
});
const lastBadge = Badge.create({
id: 2,
name: "Zoo Badge",
enabled: true,
manually_grantable: true,
multiple_grant: true,
});
const grantedBadge = Badge.create({
id: 6,
name: "Grant Badge",
enabled: true,
manually_grantable: true,
multiple_grant: false,
});
const disabledBadge = Badge.create({
id: 4,
name: "Disabled Badge",
enabled: false,
manually_grantable: true,
});
const automaticBadge = Badge.create({
id: 5,
name: "Automatic Badge",
enabled: true,
manually_grantable: false,
});
const allBadges = [
lastBadge,
firstBadge,
middleBadge,
grantedBadge,
disabledBadge,
automaticBadge,
];
const userBadges = [lastBadge, grantedBadge];
test("grantableBadges", function (assert) {
const sortedNames = [firstBadge.name, middleBadge.name, lastBadge.name];
const result = grantableBadges(allBadges, userBadges);
const badgeNames = result.map((b) => b.name);
assert.deepEqual(badgeNames, sortedNames, "sorts badges by name");
assert.notOk(
badgeNames.includes(grantedBadge.name),
"excludes already granted badges"
);
assert.notOk(
badgeNames.includes(disabledBadge.name),
"excludes disabled badges"
);
assert.notOk(
badgeNames.includes(automaticBadge.name),
"excludes automatic badges"
);
assert.ok(
badgeNames.includes(lastBadge.name),
"includes granted badges that can be granted multiple times"
);
});
test("isBadgeGrantable", function (assert) {
const badges = [firstBadge, lastBadge];
assert.ok(isBadgeGrantable(firstBadge.id, badges));
assert.notOk(
isBadgeGrantable(disabledBadge.id, badges),
"returns false when badgeId is not that of any badge in availableBadges"
);
assert.notOk(
isBadgeGrantable(firstBadge.id),
"returns false if no availableBadges is defined"
);
});
});
});

View File

@ -1,81 +0,0 @@
import { module, test } from "qunit";
import Badge from "discourse/models/badge";
import Controller from "@ember/controller";
import GrantBadgeControllerMixin from "discourse/mixins/grant-badge-controller";
module("Unit | Mixin | grant-badge-controller", function (hooks) {
hooks.beforeEach(function () {
this.GrantBadgeController = Controller.extend(GrantBadgeControllerMixin);
this.badgeFirst = Badge.create({
id: 3,
name: "A Badge",
enabled: true,
manually_grantable: true,
});
this.badgeMiddle = Badge.create({
id: 1,
name: "My Badge",
enabled: true,
manually_grantable: true,
});
this.badgeLast = Badge.create({
id: 2,
name: "Zoo Badge",
enabled: true,
manually_grantable: true,
});
this.badgeDisabled = Badge.create({
id: 4,
name: "Disabled Badge",
enabled: false,
manually_grantable: true,
});
this.badgeAutomatic = Badge.create({
id: 5,
name: "Automatic Badge",
enabled: true,
manually_grantable: false,
});
this.subject = this.GrantBadgeController.create({
userBadges: [],
allBadges: [
this.badgeLast,
this.badgeFirst,
this.badgeMiddle,
this.badgeDisabled,
this.badgeAutomatic,
],
});
});
test("grantableBadges", function (assert) {
const sortedNames = [
this.badgeFirst.name,
this.badgeMiddle.name,
this.badgeLast.name,
];
const badgeNames = this.subject
.get("grantableBadges")
.map((badge) => badge.name);
assert.notOk(
badgeNames.includes(this.badgeDisabled),
"excludes disabled badges"
);
assert.notOk(
badgeNames.includes(this.badgeAutomatic),
"excludes automatic badges"
);
assert.deepEqual(badgeNames, sortedNames, "sorts badges by name");
});
test("selectedBadgeGrantable", function (assert) {
this.subject.set("selectedBadgeId", this.badgeDisabled.id);
assert.notOk(this.subject.get("selectedBadgeGrantable"));
this.subject.set("selectedBadgeId", this.badgeFirst.id);
assert.ok(this.subject.get("selectedBadgeGrantable"));
});
});