DEV: Add Settings tab to admin Badges page (#32251)

This change does two things:

Modernizes the admin badges UI implementation. (Routes + controllers → components + services.)
Adds a Settings tab to the new badges page.
For all intents and purposes, this change is a lift-and-shift modernization. The addition of the settings tab is trivial once that is covered.
This commit is contained in:
Ted Johansson 2025-04-21 09:41:29 +08:00 committed by GitHub
parent 1e0d773b54
commit e8997b6202
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1057 additions and 1234 deletions

View File

@ -0,0 +1,197 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { Input } from "@ember/component";
import { action } from "@ember/object";
import { LinkTo } from "@ember/routing";
import { service } from "@ember/service";
import DButton from "discourse/components/d-button";
import icon from "discourse/helpers/d-icon";
import iconOrImage from "discourse/helpers/icon-or-image";
import { ajax } from "discourse/lib/ajax";
import { extractError } from "discourse/lib/ajax-error";
import { i18n } from "discourse-i18n";
import AdminBadgesList from "admin/components/admin-badges-list";
export default class AdminBadgesAward extends Component {
@service adminBadges;
@service dialog;
@tracked saving = false;
@tracked replaceBadgeOwners = false;
@tracked grantExistingHolders = false;
@tracked fileSelected = false;
@tracked unmatchedEntries = null;
@tracked resultsMessage = null;
@tracked success = false;
@tracked unmatchedEntriesCount = 0;
get badges() {
return this.adminBadges.badges;
}
resetState() {
this.saving = false;
this.unmatchedEntries = null;
this.resultsMessage = null;
this.success = false;
this.unmatchedEntriesCount = 0;
this.updateFileSelected();
}
get massAwardButtonDisabled() {
return !this.fileSelected || this.saving;
}
get unmatchedEntriesTruncated() {
let count = this.unmatchedEntriesCount;
let length = this.unmatchedEntries.length;
return count && length && count > length;
}
@action
updateFileSelected() {
this.fileSelected = !!document.querySelector("#massAwardCSVUpload")?.files
?.length;
}
@action
massAward() {
const file = document.querySelector("#massAwardCSVUpload").files[0];
if (this.args.badge && file) {
const options = {
type: "POST",
processData: false,
contentType: false,
data: new FormData(),
};
options.data.append("file", file);
options.data.append("replace_badge_owners", this.replaceBadgeOwners);
options.data.append("grant_existing_holders", this.grantExistingHolders);
this.resetState();
this.saving = true;
ajax(`/admin/badges/award/${this.args.badge.id}`, options)
.then(
({
matched_users_count: matchedCount,
unmatched_entries: unmatchedEntries,
unmatched_entries_count: unmatchedEntriesCount,
}) => {
this.resultsMessage = i18n("admin.badges.mass_award.success", {
count: matchedCount,
});
this.success = true;
if (unmatchedEntries.length) {
this.unmatchedEntries = unmatchedEntries;
this.unmatchedEntriesCount = unmatchedEntriesCount;
}
}
)
.catch((error) => {
this.resultsMessage = extractError(error);
this.success = false;
})
.finally(() => (this.saving = false));
} else {
this.dialog.alert(i18n("admin.badges.mass_award.aborted"));
}
}
<template>
<AdminBadgesList @badges={{this.badges}} />
<section class="current-badge content-body">
<h2>{{i18n "admin.badges.mass_award.title"}}</h2>
<p>{{i18n "admin.badges.mass_award.description"}}</p>
{{#if @badge}}
<form class="form-horizontal">
<div class="badge-preview control-group">
{{iconOrImage @badge}}
<span class="badge-display-name">{{@badge.name}}</span>
</div>
<div class="control-group">
<h4>{{i18n "admin.badges.mass_award.upload_csv"}}</h4>
<input
type="file"
id="massAwardCSVUpload"
accept=".csv"
onchange={{this.updateFileSelected}}
/>
</div>
<div class="control-group">
<label class="checkbox-label">
<Input @type="checkbox" @checked={{this.replaceBadgeOwners}} />
{{i18n "admin.badges.mass_award.replace_owners"}}
</label>
{{#if @badge.multiple_grant}}
<label class="grant-existing-holders">
<Input
@type="checkbox"
@checked={{this.grantExistingHolders}}
class="grant-existing-holders-checkbox"
/>
{{i18n "admin.badges.mass_award.grant_existing_holders"}}
</label>
{{/if}}
</div>
<DButton
@action={{this.massAward}}
@disabled={{this.massAwardButtonDisabled}}
@icon="certificate"
@label="admin.badges.mass_award.perform"
type="submit"
class="btn-primary"
/>
<LinkTo @route="adminBadges.index" class="btn btn-normal">
{{icon "xmark"}}
<span>{{i18n "cancel"}}</span>
</LinkTo>
</form>
{{#if this.saving}}
{{i18n "uploading"}}
{{/if}}
{{#if this.resultsMessage}}
<p>
{{#if this.success}}
{{icon "check" class="bulk-award-status-icon success"}}
{{else}}
{{icon "xmark" class="bulk-award-status-icon failure"}}
{{/if}}
{{this.resultsMessage}}
</p>
{{#if this.unmatchedEntries.length}}
<p>
{{icon
"triangle-exclamation"
class="bulk-award-status-icon failure"
}}
<span>
{{#if this.unmatchedEntriesTruncated}}
{{i18n
"admin.badges.mass_award.csv_has_unmatched_users_truncated_list"
count=this.unmatchedEntriesCount
}}
{{else}}
{{i18n "admin.badges.mass_award.csv_has_unmatched_users"}}
{{/if}}
</span>
</p>
<ul>
{{#each this.unmatchedEntries as |entry|}}
<li>{{entry}}</li>
{{/each}}
</ul>
{{/if}}
{{/if}}
{{else}}
<span class="badge-required">{{i18n
"admin.badges.mass_award.no_badge_selected"
}}</span>
{{/if}}
</section>
</template>
}

View File

@ -0,0 +1,20 @@
import Component from "@glimmer/component";
import { service } from "@ember/service";
import { i18n } from "discourse-i18n";
import AdminBadgesList from "admin/components/admin-badges-list";
export default class AdminBadgesIndex extends Component {
@service adminBadges;
get badges() {
return this.adminBadges.badges;
}
<template>
<AdminBadgesList @badges={{this.badges}} />
<section class="current-badge content-body">
<h2>{{i18n "admin.badges.badge_intro.title"}}</h2>
<p>{{i18n "admin.badges.badge_intro.description"}}</p>
</section>
</template>
}

View File

@ -0,0 +1,39 @@
import Component from "@glimmer/component";
import { LinkTo } from "@ember/routing";
import { service } from "@ember/service";
import BadgeButton from "discourse/components/badge-button";
import { i18n } from "discourse-i18n";
export default class AdminBadgesList extends Component {
@service router;
get selectedRoute() {
const currentRoute = this.router.currentRouteName;
if (currentRoute === "adminBadges.index") {
return "adminBadges.show";
} else {
return currentRoute;
}
}
<template>
<div class="content-list">
<ul class="admin-badge-list">
{{#each @badges as |badge|}}
<li class="admin-badge-list-item">
<LinkTo @route={{this.selectedRoute}} @model={{badge.id}}>
<BadgeButton @badge={{badge}} />
{{#if badge.newBadge}}
<span class="list-badge">{{i18n
"filters.new.lower_title"
}}</span>
{{/if}}
</LinkTo>
</li>
{{/each}}
</ul>
</div>
{{outlet}}
</template>
}

View File

@ -0,0 +1,576 @@
import Component from "@glimmer/component";
import { cached, tracked } from "@glimmer/tracking";
import { concat, fn, hash } from "@ember/helper";
import { action, getProperties } from "@ember/object";
import { LinkTo } from "@ember/routing";
import { service } from "@ember/service";
import Form from "discourse/components/form";
import PluginOutlet from "discourse/components/plugin-outlet";
import icon from "discourse/helpers/d-icon";
import iconOrImage from "discourse/helpers/icon-or-image";
import routeAction from "discourse/helpers/route-action";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import getURL from "discourse/lib/get-url";
import { i18n } from "discourse-i18n";
import AdminBadgesList from "admin/components/admin-badges-list";
import BadgePreviewModal from "admin/components/modal/badge-preview";
const FORM_FIELDS = [
"allow_title",
"multiple_grant",
"listable",
"auto_revoke",
"enabled",
"show_posts",
"target_posts",
"name",
"description",
"long_description",
"icon",
"image_upload_id",
"image_url",
"query",
"badge_grouping_id",
"trigger",
"badge_type_id",
"show_in_post_header",
];
export default class AdminBadgesShow extends Component {
@service adminBadges;
@service dialog;
@service modal;
@service router;
@service siteSettings;
@service toasts;
@tracked previewLoading = false;
get badges() {
return this.adminBadges.badges;
}
get badgeTypes() {
return this.adminBadges.badgeTypes;
}
get badgeGroupings() {
return this.adminBadges.badgeGroupings;
}
@action
currentBadgeGrouping(data) {
return this.adminBadges.badgeGroupings.find(
(bg) => bg.id === data.badge_grouping_id
)?.name;
}
get badgeTriggers() {
return this.adminBadges.badgeTriggers;
}
get readOnly() {
return this.args.badge.system;
}
get textCustomizationPrefix() {
return `badges.${this.args.badge.i18n_name}.`;
}
hasQuery(query) {
return query?.trim?.()?.length > 0;
}
// Form methods.
@cached
get formData() {
const data = getProperties(this.args.badge, ...FORM_FIELDS);
if (data.icon === "") {
data.icon = undefined;
}
return data;
}
@action
postHeaderDescription(data) {
return this.disableBadgeOnPosts(data) && !data.system;
}
@action
disableBadgeOnPosts(data) {
const { listable, show_posts } = data;
return !listable || !show_posts;
}
@action
onSetImage(upload, { set }) {
if (upload) {
set("image_upload_id", upload.id);
set("image_url", getURL(upload.url));
set("icon", null);
} else {
set("image_upload_id", "");
set("image_url", "");
}
}
@action
onSetIcon(value, { set }) {
set("icon", value);
set("image_upload_id", "");
set("image_url", "");
}
@action
showPreview(badge, explain, event) {
event?.preventDefault();
this.preview(badge, explain);
}
@action
async preview(badge, explain) {
try {
this.previewLoading = true;
const model = await ajax("/admin/badges/preview.json", {
type: "POST",
data: {
sql: badge.query,
target_posts: !!badge.target_posts,
trigger: badge.trigger,
explain,
},
});
this.modal.show(BadgePreviewModal, { model: { badge: model } });
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
this.dialog.alert("Network error");
} finally {
this.previewLoading = false;
}
}
@action
validateForm(data, { addError, removeError }) {
if (!data.icon && !data.image_url) {
addError("icon", {
title: "Icon",
message: i18n("admin.badges.icon_or_image"),
});
addError("image_url", {
title: "Image",
message: i18n("admin.badges.icon_or_image"),
});
} else {
removeError("image_url");
removeError("icon");
}
}
@action
async handleSubmit(formData) {
let fields = FORM_FIELDS;
if (formData.system) {
const protectedFields = this.protectedSystemFields || [];
fields = fields.filter((f) => !protectedFields.includes(f));
}
const data = {};
fields.forEach(function (field) {
data[field] = formData[field];
});
const newBadge = !this.args.badge.id;
try {
const badge = await this.args.badge.save(data);
this.toasts.success({ data: { message: i18n("saved") } });
if (newBadge) {
const adminBadges = this.adminBadges.badges;
if (!adminBadges.includes(badge)) {
adminBadges.push(badge);
}
return this.router.transitionTo("adminBadges.show", badge.id);
}
} catch (error) {
return popupAjaxError(error);
}
}
@action
registerApi(api) {
this.formApi = api;
}
@action
async handleDelete() {
if (!this.args.badge?.id) {
return this.router.transitionTo("adminBadges.index");
}
return this.dialog.yesNoConfirm({
message: i18n("admin.badges.delete_confirm"),
didConfirm: async () => {
try {
await this.formApi.reset();
await this.args.badge.destroy();
this.adminBadges.badges = this.adminBadges.badges.filter(
(badge) => badge.id !== this.args.badge.id
);
this.router.transitionTo("adminBadges.index");
} catch {
this.dialog.alert(i18n("generic_error"));
}
},
});
}
<template>
<AdminBadgesList @badges={{this.badges}} />
{{#if @badge}}
<Form
@data={{this.formData}}
@onSubmit={{this.handleSubmit}}
@validate={{this.validateForm}}
@onRegisterApi={{this.registerApi}}
class="badge-form current-badge content-body"
as |form data|
>
<h2 class="current-badge-header">
{{iconOrImage data}}
<span class="badge-display-name">{{data.name}}</span>
</h2>
<form.Field
@name="enabled"
@validation="required"
@title={{i18n "admin.badges.status"}}
as |field|
>
<field.Question
@yesLabel={{i18n "admin.badges.enabled"}}
@noLabel={{i18n "admin.badges.disabled"}}
/>
</form.Field>
{{#if this.readOnly}}
<form.Container data-name="name" @title={{i18n "admin.badges.name"}}>
<span class="readonly-field">
{{@badge.name}}
</span>
<LinkTo
@route="adminSiteText"
@query={{hash q=(concat this.textCustomizationPrefix "name")}}
>
{{icon "pencil"}}
</LinkTo>
</form.Container>
{{else}}
<form.Field
@title={{i18n "admin.badges.name"}}
@name="name"
@disabled={{this.readOnly}}
@validation="required"
as |field|
>
<field.Input />
</form.Field>
{{/if}}
<form.Section @title="Design">
<form.Field
@name="badge_type_id"
@title={{i18n "admin.badges.badge_type"}}
@validation="required"
@disabled={{this.readOnly}}
as |field|
>
<field.Select as |select|>
{{#each this.badgeTypes as |badgeType|}}
<select.Option @value={{badgeType.id}}>
{{badgeType.name}}
</select.Option>
{{/each}}
</field.Select>
</form.Field>
<form.ConditionalContent
@activeName={{if data.image_url "upload-image" "choose-icon"}}
as |cc|
>
<cc.Conditions as |Condition|>
<Condition @name="choose-icon">
{{i18n "admin.badges.select_an_icon"}}
</Condition>
<Condition @name="upload-image">
{{i18n "admin.badges.upload_an_image"}}
</Condition>
</cc.Conditions>
<cc.Contents as |Content|>
<Content @name="choose-icon">
<form.Field
@title={{i18n "admin.badges.icon"}}
@showTitle={{false}}
@name="icon"
@onSet={{this.onSetIcon}}
@format="small"
as |field|
>
<field.Icon />
</form.Field>
</Content>
<Content @name="upload-image">
<form.Field
@name="image_url"
@showTitle={{false}}
@title={{i18n "admin.badges.image"}}
@onSet={{this.onSetImage}}
as |field|
>
<field.Image @type="badge_image" />
</form.Field>
</Content>
</cc.Contents>
</form.ConditionalContent>
{{#if this.readOnly}}
<form.Container
data-name="description"
@title={{i18n "admin.badges.description"}}
>
<span class="readonly-field">
{{@badge.description}}
</span>
<LinkTo
@route="adminSiteText"
@query={{hash
q=(concat this.textCustomizationPrefix "description")
}}
>
{{icon "pencil"}}
</LinkTo>
</form.Container>
{{else}}
<form.Field
@title={{i18n "admin.badges.description"}}
@name="description"
@disabled={{this.readOnly}}
as |field|
>
<field.Textarea />
</form.Field>
{{/if}}
{{#if this.readOnly}}
<form.Container
data-name="long_description"
@title={{i18n "admin.badges.long_description"}}
>
<span class="readonly-field">
{{@badge.long_description}}
</span>
<LinkTo
@route="adminSiteText"
@query={{hash
q=(concat this.textCustomizationPrefix "long_description")
}}
>
{{icon "pencil"}}
</LinkTo>
</form.Container>
{{else}}
<form.Field
@name="long_description"
@title={{i18n "admin.badges.long_description"}}
@disabled={{this.readOnly}}
as |field|
>
<field.Textarea />
</form.Field>
{{/if}}
</form.Section>
{{#if this.siteSettings.enable_badge_sql}}
<form.Section @title="Query">
<form.Field
@name="query"
@title={{i18n "admin.badges.query"}}
@disabled={{this.readOnly}}
as |field|
>
<field.Code @lang="sql" />
</form.Field>
{{#if (this.hasQuery data.query)}}
<form.Container>
<form.Button
@isLoading={{this.previewLoading}}
@label="admin.badges.preview.link_text"
class="preview-badge"
@action={{fn this.showPreview data "false"}}
/>
<form.Button
@isLoading={{this.previewLoading}}
@label="admin.badges.preview.plan_text"
class="preview-badge-plan"
@action={{fn this.showPreview data "true"}}
/>
</form.Container>
<form.CheckboxGroup as |group|>
<group.Field
@name="auto_revoke"
@disabled={{this.readOnly}}
@showTitle={{false}}
@title={{i18n "admin.badges.auto_revoke"}}
as |field|
>
<field.Checkbox />
</group.Field>
<group.Field
@name="target_posts"
@disabled={{this.readOnly}}
@title={{i18n "admin.badges.target_posts"}}
@showTitle={{false}}
as |field|
>
<field.Checkbox />
</group.Field>
</form.CheckboxGroup>
<form.Field
@name="trigger"
@disabled={{this.readOnly}}
@validation="required"
@title={{i18n "admin.badges.trigger"}}
as |field|
>
<field.Select as |select|>
{{#each this.badgeTriggers as |badgeTrigger|}}
<select.Option @value={{badgeTrigger.id}}>
{{badgeTrigger.name}}
</select.Option>
{{/each}}
</field.Select>
</form.Field>
{{/if}}
</form.Section>
{{/if}}
<form.Section @title="Settings">
<form.Field
@name="badge_grouping_id"
@disabled={{this.readOnly}}
@validation="required"
@title={{i18n "admin.badges.badge_grouping"}}
as |field|
>
<field.Menu @selection={{this.currentBadgeGrouping data}} as |menu|>
{{#each this.badgeGroupings as |grouping|}}
<menu.Item @value={{grouping.id}}>{{grouping.name}}</menu.Item>
{{/each}}
<menu.Divider />
<menu.Item @action={{routeAction "editGroupings"}}>Add new group</menu.Item>
</field.Menu>
</form.Field>
<form.CheckboxGroup
@title={{i18n "admin.badges.usage_heading"}}
as |group|
>
<group.Field
@title={{i18n "admin.badges.allow_title"}}
@showTitle={{false}}
@name="allow_title"
@format="full"
as |field|
>
<field.Checkbox />
</group.Field>
<group.Field
@title={{i18n "admin.badges.multiple_grant"}}
@showTitle={{false}}
@name="multiple_grant"
@disabled={{this.readOnly}}
@format="full"
as |field|
>
<field.Checkbox />
</group.Field>
</form.CheckboxGroup>
<form.CheckboxGroup
@title={{i18n "admin.badges.visibility_heading"}}
as |group|
>
<group.Field
@title={{i18n "admin.badges.listable"}}
@showTitle={{false}}
@name="listable"
@disabled={{this.readOnly}}
@format="full"
as |field|
>
<field.Checkbox />
</group.Field>
<group.Field
@title={{i18n "admin.badges.show_posts"}}
@showTitle={{false}}
@name="show_posts"
@disabled={{this.readOnly}}
@format="full"
as |field|
>
<field.Checkbox />
</group.Field>
<group.Field
@title={{i18n "admin.badges.show_in_post_header"}}
@showTitle={{false}}
@name="show_in_post_header"
@disabled={{this.disableBadgeOnPosts data}}
@format="full"
as |field|
>
<field.Checkbox>
{{#if (this.postHeaderDescription data)}}
{{i18n "admin.badges.show_in_post_header_disabled"}}
{{/if}}
</field.Checkbox>
</group.Field>
</form.CheckboxGroup>
</form.Section>
<PluginOutlet
@name="admin-above-badge-buttons"
@outletArgs={{hash badge=this.buffered form=form}}
/>
<form.Actions>
<form.Submit />
{{#unless this.readOnly}}
<form.Button
@action={{this.handleDelete}}
class="badge-form__delete-badge-btn btn-danger"
>
{{i18n "admin.badges.delete"}}
</form.Button>
{{/unless}}
</form.Actions>
</Form>
{{/if}}
</template>
}

View File

@ -0,0 +1,88 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { service } from "@ember/service";
import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item";
import DPageHeader from "discourse/components/d-page-header";
import NavItem from "discourse/components/nav-item";
import { i18n } from "discourse-i18n";
import EditBadgeGroupingsModal from "admin/components/modal/edit-badge-groupings";
export default class AdminBadges extends Component {
@service adminBadges;
@service modal;
get badges() {
return this.adminBadges.badges;
}
@action
editGroupings() {
this.modal.show(EditBadgeGroupingsModal, {
model: {
badgeGroupings: this.adminBadges.badgeGroupings,
updateGroupings: (groupings) => {
this.adminBadges.badgeGroupings = groupings;
},
},
});
}
<template>
<div class="badges">
<DPageHeader
@titleLabel={{i18n "admin.config.badges.title"}}
@descriptionLabel={{i18n "admin.config.badges.header_description"}}
@learnMoreUrl="https://meta.discourse.org/t/understanding-and-using-badges/32540"
>
<:breadcrumbs>
<DBreadcrumbsItem @path="/admin" @label={{i18n "admin_title"}} />
<DBreadcrumbsItem
@path="/admin/badges"
@label={{i18n "admin.config.badges.title"}}
/>
</:breadcrumbs>
<:actions as |actions|>
<actions.Primary
@route="adminBadges.show"
@routeModels="new"
@icon="plus"
@label="admin.badges.new"
class="new-badge"
/>
<actions.Default
@route="adminBadges.award"
@routeModels="new"
@icon="upload"
@label="admin.badges.mass_award.title"
class="award-badge"
/>
<actions.Default
@action={{this.editGroupings}}
@title="admin.badges.group_settings"
@label="admin.badges.group_settings"
@icon="gear"
class="edit-groupings-btn"
/>
</:actions>
<:tabs>
<NavItem
@route="adminBadges.settings"
@label="settings"
class="admin-badges-tabs__settings"
/>
<NavItem
@route="adminBadges.index"
@label="admin.config.badges.title"
class="admin-badges-tabs__index"
/>
</:tabs>
</DPageHeader>
<div class="admin-container admin-config-page__main-area">
{{outlet}}
</div>
</div>
</template>
}

View File

@ -1,23 +1,3 @@
import { tracked } from "@glimmer/tracking";
import Controller from "@ember/controller";
import { service } from "@ember/service";
export default class AdminBadgesController extends Controller {
@service router;
// Set by the route
@tracked badgeGroupings;
@tracked badgeTypes;
@tracked protectedSystemFields;
@tracked badgeTriggers;
get selectedRoute() {
const currentRoute = this.router.currentRouteName;
const indexRoute = "adminBadges.index";
if (currentRoute === indexRoute) {
return "adminBadges.show";
} else {
return currentRoute;
}
}
}
export default class AdminBadgesController extends Controller {}

View File

@ -1,92 +1,3 @@
import { tracked } from "@glimmer/tracking";
import Controller from "@ember/controller";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { ajax } from "discourse/lib/ajax";
import { extractError } from "discourse/lib/ajax-error";
import { i18n } from "discourse-i18n";
export default class AdminBadgesAwardController extends Controller {
@service dialog;
@tracked saving = false;
@tracked replaceBadgeOwners = false;
@tracked grantExistingHolders = false;
@tracked fileSelected = false;
@tracked unmatchedEntries = null;
@tracked resultsMessage = null;
@tracked success = false;
@tracked unmatchedEntriesCount = 0;
resetState() {
this.saving = false;
this.unmatchedEntries = null;
this.resultsMessage = null;
this.success = false;
this.unmatchedEntriesCount = 0;
this.updateFileSelected();
}
get massAwardButtonDisabled() {
return !this.fileSelected || this.saving;
}
get unmatchedEntriesTruncated() {
let count = this.unmatchedEntriesCount;
let length = this.unmatchedEntries.length;
return count && length && count > length;
}
@action
updateFileSelected() {
this.fileSelected = !!document.querySelector("#massAwardCSVUpload")?.files
?.length;
}
@action
massAward() {
const file = document.querySelector("#massAwardCSVUpload").files[0];
if (this.model && file) {
const options = {
type: "POST",
processData: false,
contentType: false,
data: new FormData(),
};
options.data.append("file", file);
options.data.append("replace_badge_owners", this.replaceBadgeOwners);
options.data.append("grant_existing_holders", this.grantExistingHolders);
this.resetState();
this.saving = true;
ajax(`/admin/badges/award/${this.model.id}`, options)
.then(
({
matched_users_count: matchedCount,
unmatched_entries: unmatchedEntries,
unmatched_entries_count: unmatchedEntriesCount,
}) => {
this.resultsMessage = i18n("admin.badges.mass_award.success", {
count: matchedCount,
});
this.success = true;
if (unmatchedEntries.length) {
this.unmatchedEntries = unmatchedEntries;
this.unmatchedEntriesCount = unmatchedEntriesCount;
}
}
)
.catch((error) => {
this.resultsMessage = extractError(error);
this.success = false;
})
.finally(() => (this.saving = false));
} else {
this.dialog.alert(i18n("admin.badges.mass_award.aborted"));
}
}
}
export default class AdminBadgesAwardController extends Controller {}

View File

@ -1,8 +1,3 @@
import { tracked } from "@glimmer/tracking";
import Controller from "@ember/controller";
export default class AdminBadgesIndexController extends Controller {
// Set by the route
@tracked badgeIntroLinks;
@tracked badgeIntroEmoji;
}
export default class AdminBadgesIndexController extends Controller {}

View File

@ -0,0 +1,3 @@
import AdminAreaSettingsBaseController from "admin/controllers/admin-area-settings-base";
export default class AdminBadgesSettingsController extends AdminAreaSettingsBaseController {}

View File

@ -1,243 +1,3 @@
import { cached, tracked } from "@glimmer/tracking";
import Controller, { inject as controller } from "@ember/controller";
import { action, getProperties } from "@ember/object";
import { service } from "@ember/service";
import { isNone } from "@ember/utils";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import getURL from "discourse/lib/get-url";
import { i18n } from "discourse-i18n";
import BadgePreviewModal from "../../components/modal/badge-preview";
import Controller from "@ember/controller";
const FORM_FIELDS = [
"allow_title",
"multiple_grant",
"listable",
"auto_revoke",
"enabled",
"show_posts",
"target_posts",
"name",
"description",
"long_description",
"icon",
"image_upload_id",
"image_url",
"query",
"badge_grouping_id",
"trigger",
"badge_type_id",
"show_in_post_header",
];
export default class AdminBadgesShowController extends Controller {
@service router;
@service toasts;
@service dialog;
@service modal;
@controller adminBadges;
@tracked model;
@tracked previewLoading = false;
@tracked selectedGraphicType = null;
@cached
get formData() {
const data = getProperties(this.model, ...FORM_FIELDS);
if (data.icon === "") {
data.icon = undefined;
}
return data;
}
@action
currentBadgeGrouping(data) {
return this.badgeGroupings.find((bg) => bg.id === data.badge_grouping_id)
?.name;
}
get badgeTypes() {
return this.adminBadges.badgeTypes;
}
get badgeGroupings() {
return this.adminBadges.badgeGroupings;
}
get badgeTriggers() {
return this.adminBadges.badgeTriggers;
}
get protectedSystemFields() {
return this.adminBadges.protectedSystemFields;
}
get readOnly() {
return this.model.system;
}
@action
postHeaderDescription(data) {
return this.disableBadgeOnPosts(data) && !data.system;
}
@action
disableBadgeOnPosts(data) {
const { listable, show_posts } = data;
return !listable || !show_posts;
}
setup() {
// this is needed because the model doesnt have default values
// Using `set` here isn't ideal, but we don't know that tracking is set up on the model yet.
if (this.model) {
if (isNone(this.model.badge_type_id)) {
this.model.set("badge_type_id", this.badgeTypes?.[0]?.id);
}
if (isNone(this.model.badge_grouping_id)) {
this.model.set("badge_grouping_id", this.badgeGroupings?.[0]?.id);
}
if (isNone(this.model.trigger)) {
this.model.set("trigger", this.badgeTriggers?.[0]?.id);
}
}
}
hasQuery(query) {
return query?.trim?.()?.length > 0;
}
get textCustomizationPrefix() {
return `badges.${this.model.i18n_name}.`;
}
@action
onSetImage(upload, { set }) {
if (upload) {
set("image_upload_id", upload.id);
set("image_url", getURL(upload.url));
set("icon", null);
} else {
set("image_upload_id", "");
set("image_url", "");
}
}
@action
onSetIcon(value, { set }) {
set("icon", value);
set("image_upload_id", "");
set("image_url", "");
}
@action
showPreview(badge, explain, event) {
event?.preventDefault();
this.preview(badge, explain);
}
@action
validateForm(data, { addError, removeError }) {
if (!data.icon && !data.image_url) {
addError("icon", {
title: "Icon",
message: i18n("admin.badges.icon_or_image"),
});
addError("image_url", {
title: "Image",
message: i18n("admin.badges.icon_or_image"),
});
} else {
removeError("image_url");
removeError("icon");
}
}
@action
async preview(badge, explain) {
try {
this.previewLoading = true;
const model = await ajax("/admin/badges/preview.json", {
type: "POST",
data: {
sql: badge.query,
target_posts: !!badge.target_posts,
trigger: badge.trigger,
explain,
},
});
this.modal.show(BadgePreviewModal, { model: { badge: model } });
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
this.dialog.alert("Network error");
} finally {
this.previewLoading = false;
}
}
@action
async handleSubmit(formData) {
let fields = FORM_FIELDS;
if (formData.system) {
const protectedFields = this.protectedSystemFields || [];
fields = fields.filter((f) => !protectedFields.includes(f));
}
const data = {};
fields.forEach(function (field) {
data[field] = formData[field];
});
const newBadge = !this.model.id;
try {
this.model = await this.model.save(data);
this.toasts.success({ data: { message: i18n("saved") } });
if (newBadge) {
const adminBadges = this.get("adminBadges.model");
if (!adminBadges.includes(this.model)) {
adminBadges.pushObject(this.model);
}
return this.router.transitionTo("adminBadges.show", this.model.id);
}
} catch (error) {
return popupAjaxError(error);
}
}
@action
registerApi(api) {
this.formApi = api;
}
@action
async handleDelete() {
if (!this.model?.id) {
return this.router.transitionTo("adminBadges.index");
}
return this.dialog.yesNoConfirm({
message: i18n("admin.badges.delete_confirm"),
didConfirm: async () => {
try {
await this.formApi.reset();
await this.model.destroy();
this.adminBadges.model.removeObject(this.model);
this.router.transitionTo("adminBadges.index");
} catch {
this.dialog.alert(i18n("generic_error"));
}
},
});
}
}
export default class AdminBadgesShowController extends Controller {}

View File

@ -1,65 +1,15 @@
import { action } from "@ember/object";
import { service } from "@ember/service";
import { ajax } from "discourse/lib/ajax";
import Badge from "discourse/models/badge";
import BadgeGrouping from "discourse/models/badge-grouping";
import DiscourseRoute from "discourse/routes/discourse";
import { i18n } from "discourse-i18n";
import EditBadgeGroupingsModal from "../components/modal/edit-badge-groupings";
export default class AdminBadgesRoute extends DiscourseRoute {
@service modal;
_json = null;
@service adminBadges;
titleToken() {
return i18n("admin.config.badges.title");
}
async model() {
let json = await ajax("/admin/badges.json");
this._json = json;
return Badge.createFromJson(json);
}
@action
editGroupings() {
const model = this.controllerFor("admin-badges").badgeGroupings;
this.modal.show(EditBadgeGroupingsModal, {
model: {
badgeGroupings: model,
updateGroupings: this.updateGroupings,
},
});
}
@action
updateGroupings(groupings) {
this.controllerFor("admin-badges").set("badgeGroupings", groupings);
}
setupController(controller, model) {
const json = this._json;
const badgeTriggers = [];
const badgeGroupings = [];
Object.keys(json.admin_badges.triggers).forEach((k) => {
const id = json.admin_badges.triggers[k];
badgeTriggers.push({
id,
name: i18n("admin.badges.trigger_type." + k),
});
});
json.badge_groupings.forEach(function (badgeGroupingJson) {
badgeGroupings.push(BadgeGrouping.create(badgeGroupingJson));
});
controller.badgeGroupings = badgeGroupings;
controller.badgeTypes = json.badge_types;
controller.protectedSystemFields =
json.admin_badges.protected_system_fields;
controller.badgeTriggers = badgeTriggers;
controller.model = model;
await this.adminBadges.fetchBadges();
}
}

View File

@ -1,17 +1,16 @@
import { service } from "@ember/service";
import Route from "discourse/routes/discourse";
export default class AdminBadgesAwardRoute extends Route {
model(params) {
if (params.badge_id !== "new") {
return this.modelFor("adminBadges").findBy(
"id",
parseInt(params.badge_id, 10)
);
}
}
@service adminBadges;
setupController(controller) {
super.setupController(...arguments);
controller.resetState();
async model(params) {
await this.adminBadges.fetchBadges();
if (params.badge_id === "new") {
return;
}
return this.adminBadges.badges.findBy("id", parseInt(params.badge_id, 10));
}
}

View File

@ -1,8 +1,10 @@
import Route from "@ember/routing/route";
import { emojiUrlFor } from "discourse/lib/text";
import { service } from "@ember/service";
export default class AdminBadgesIndexRoute extends Route {
setupController(controller) {
controller.badgeIntroEmoji = emojiUrlFor("woman_student:t4");
@service adminBadges;
async model() {
await this.adminBadges.fetchBadges();
}
}

View File

@ -0,0 +1,8 @@
import DiscourseRoute from "discourse/routes/discourse";
import { i18n } from "discourse-i18n";
export default class AdminBadgesSettingsRoute extends DiscourseRoute {
titleToken() {
return i18n("settings");
}
}

View File

@ -6,26 +6,24 @@ import { i18n } from "discourse-i18n";
export default class AdminBadgesShowRoute extends Route {
@service dialog;
@service adminBadges;
serialize(m) {
return { badge_id: get(m, "id") || "new" };
serialize(model) {
return { badge_id: get(model, "id") || "new" };
}
model(params) {
async model(params) {
await this.adminBadges.fetchBadges();
if (params.badge_id === "new") {
return Badge.create({
name: i18n("admin.badges.new_badge"),
badge_type_id: this.adminBadges.badgeTypes[0].id,
badge_grouping_id: this.adminBadges.badgeGroupings[0].id,
trigger: this.adminBadges.badgeTriggers[0].id,
});
}
return this.modelFor("adminBadges").findBy(
"id",
parseInt(params.badge_id, 10)
);
}
setupController(controller) {
super.setupController(...arguments);
controller.setup();
return this.adminBadges.badges.findBy("id", parseInt(params.badge_id, 10));
}
}

View File

@ -213,6 +213,7 @@ export default function () {
"adminBadges",
{ path: "/badges", resetNamespace: true },
function () {
this.route("settings");
this.route("award", { path: "/award/:badge_id" });
this.route("show", { path: "/:badge_id" });
}

View File

@ -0,0 +1,60 @@
import { tracked } from "@glimmer/tracking";
import Service from "@ember/service";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import Badge from "discourse/models/badge";
import BadgeGrouping from "discourse/models/badge-grouping";
import { i18n } from "discourse-i18n";
export default class AdminBadges extends Service {
@tracked data;
@tracked badges;
@tracked badgeGroupings = [];
get badgeTypes() {
if (!this.data) {
return [];
}
return this.data.badge_types;
}
get badgeTriggers() {
if (!this.data) {
return [];
}
return Object.keys(this.data.admin_badges.triggers).map((key) => {
return {
id: this.data.admin_badges.triggers[key],
name: i18n("admin.badges.trigger_type." + key),
};
});
}
get protectedSystemFields() {
if (!this.data) {
return [];
}
return this.data.admin_badges.protected_system_fields;
}
async fetchBadges() {
if (!this.badges) {
try {
this.data = await ajax("/admin/badges.json");
this.badges = Badge.createFromJson(this.data);
this.badgeGroupings = this.#groupBadges(this.data);
} catch (err) {
popupAjaxError(err);
}
}
}
#groupBadges(data) {
return data.badge_groupings.map((badgeGroupingJson) => {
return BadgeGrouping.create(badgeGroupingJson);
});
}
}

View File

@ -1,75 +1,4 @@
import { LinkTo } from "@ember/routing";
import RouteTemplate from "ember-route-template";
import BadgeButton from "discourse/components/badge-button";
import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item";
import DPageHeader from "discourse/components/d-page-header";
import routeAction from "discourse/helpers/route-action";
import { i18n } from "discourse-i18n";
import AdminBadges from "admin/components/admin-badges";
export default RouteTemplate(
<template>
<div class="badges">
<DPageHeader
@titleLabel={{i18n "admin.config.badges.title"}}
@descriptionLabel={{i18n "admin.config.badges.header_description"}}
@learnMoreUrl="https://meta.discourse.org/t/understanding-and-using-badges/32540"
>
<:breadcrumbs>
<DBreadcrumbsItem @path="/admin" @label={{i18n "admin_title"}} />
<DBreadcrumbsItem
@path="/admin/badges"
@label={{i18n "admin.config.badges.title"}}
/>
</:breadcrumbs>
<:actions as |actions|>
<actions.Primary
@route="adminBadges.show"
@routeModels="new"
@icon="plus"
@label="admin.badges.new"
class="new-badge"
/>
<actions.Default
@route="adminBadges.award"
@routeModels="new"
@icon="upload"
@label="admin.badges.mass_award.title"
class="award-badge"
/>
<actions.Default
@action={{routeAction "editGroupings"}}
@title="admin.badges.group_settings"
@label="admin.badges.group_settings"
@icon="gear"
class="edit-groupings-btn"
/>
</:actions>
</DPageHeader>
<div class="admin-container">
<div class="content-list">
<ul class="admin-badge-list">
{{#each @controller.model as |badge|}}
<li class="admin-badge-list-item">
<LinkTo
@route={{@controller.selectedRoute}}
@model={{badge.id}}
>
<BadgeButton @badge={{badge}} />
{{#if badge.newBadge}}
<span class="list-badge">{{i18n
"filters.new.lower_title"
}}</span>
{{/if}}
</LinkTo>
</li>
{{/each}}
</ul>
</div>
{{outlet}}
</div>
</div>
</template>
);
export default RouteTemplate(<template><AdminBadges /></template>);

View File

@ -1,111 +1,8 @@
import { Input } from "@ember/component";
import { LinkTo } from "@ember/routing";
import RouteTemplate from "ember-route-template";
import DButton from "discourse/components/d-button";
import icon from "discourse/helpers/d-icon";
import iconOrImage from "discourse/helpers/icon-or-image";
import { i18n } from "discourse-i18n";
import AdminBadgesAward from "admin/components/admin-badges-award";
export default RouteTemplate(
<template>
<section class="current-badge content-body">
<h2>{{i18n "admin.badges.mass_award.title"}}</h2>
<p>{{i18n "admin.badges.mass_award.description"}}</p>
{{#if @controller.model}}
<form class="form-horizontal">
<div class="badge-preview control-group">
{{#if @controller.model}}
{{iconOrImage @controller.model}}
<span class="badge-display-name">{{@controller.model.name}}</span>
{{else}}
<span class="badge-placeholder">{{i18n
"admin.badges.mass_award.no_badge_selected"
}}</span>
{{/if}}
</div>
<div class="control-group">
<h4>{{i18n "admin.badges.mass_award.upload_csv"}}</h4>
<input
type="file"
id="massAwardCSVUpload"
accept=".csv"
onchange={{@controller.updateFileSelected}}
/>
</div>
<div class="control-group">
<label class="checkbox-label">
<Input
@type="checkbox"
@checked={{@controller.replaceBadgeOwners}}
/>
{{i18n "admin.badges.mass_award.replace_owners"}}
</label>
{{#if @controller.model.multiple_grant}}
<label class="grant-existing-holders">
<Input
@type="checkbox"
@checked={{@controller.grantExistingHolders}}
class="grant-existing-holders-checkbox"
/>
{{i18n "admin.badges.mass_award.grant_existing_holders"}}
</label>
{{/if}}
</div>
<DButton
@action={{@controller.massAward}}
@disabled={{@controller.massAwardButtonDisabled}}
@icon="certificate"
@label="admin.badges.mass_award.perform"
type="submit"
class="btn-primary"
/>
<LinkTo @route="adminBadges.index" class="btn btn-normal">
{{icon "xmark"}}
<span>{{i18n "cancel"}}</span>
</LinkTo>
</form>
{{#if @controller.saving}}
{{i18n "uploading"}}
{{/if}}
{{#if @controller.resultsMessage}}
<p>
{{#if @controller.success}}
{{icon "check" class="bulk-award-status-icon success"}}
{{else}}
{{icon "xmark" class="bulk-award-status-icon failure"}}
{{/if}}
{{@controller.resultsMessage}}
</p>
{{#if @controller.unmatchedEntries.length}}
<p>
{{icon
"triangle-exclamation"
class="bulk-award-status-icon failure"
}}
<span>
{{#if @controller.unmatchedEntriesTruncated}}
{{i18n
"admin.badges.mass_award.csv_has_unmatched_users_truncated_list"
count=@controller.unmatchedEntriesCount
}}
{{else}}
{{i18n "admin.badges.mass_award.csv_has_unmatched_users"}}
{{/if}}
</span>
</p>
<ul>
{{#each @controller.unmatchedEntries as |entry|}}
<li>{{entry}}</li>
{{/each}}
</ul>
{{/if}}
{{/if}}
{{else}}
<span class="badge-required">{{i18n
"admin.badges.mass_award.no_badge_selected"
}}</span>
{{/if}}
</section>
<AdminBadgesAward @controller={{@controller}} @badge={{@model}} />
</template>
);

View File

@ -1,11 +1,4 @@
import RouteTemplate from "ember-route-template";
import { i18n } from "discourse-i18n";
import AdminBadgesIndex from "admin/components/admin-badges-index";
export default RouteTemplate(
<template>
<section class="current-badge content-body">
<h2>{{i18n "admin.badges.badge_intro.title"}}</h2>
<p>{{i18n "admin.badges.badge_intro.description"}}</p>
</section>
</template>
);
export default RouteTemplate(<template><AdminBadgesIndex /></template>);

View File

@ -0,0 +1,13 @@
import RouteTemplate from "ember-route-template";
import AdminAreaSettings from "admin/components/admin-area-settings";
export default RouteTemplate(
<template>
<AdminAreaSettings
@area="badges"
@path="/admin/badges/settings"
@filter={{@controller.filter}}
@adminSettingsFilterChangedCallback={{@controller.adminSettingsFilterChangedCallback}}
/>
</template>
);

View File

@ -1,375 +1,8 @@
import { concat, fn, hash } from "@ember/helper";
import { LinkTo } from "@ember/routing";
import RouteTemplate from "ember-route-template";
import Form from "discourse/components/form";
import PluginOutlet from "discourse/components/plugin-outlet";
import icon from "discourse/helpers/d-icon";
import htmlSafe from "discourse/helpers/html-safe";
import iconOrImage from "discourse/helpers/icon-or-image";
import number from "discourse/helpers/number";
import routeAction from "discourse/helpers/route-action";
import { i18n } from "discourse-i18n";
import AdminBadgesShow from "admin/components/admin-badges-show";
export default RouteTemplate(
<template>
<Form
@data={{@controller.formData}}
@onSubmit={{@controller.handleSubmit}}
@validate={{@controller.validateForm}}
@onRegisterApi={{@controller.registerApi}}
class="badge-form current-badge content-body"
as |form data|
>
<h2 class="current-badge-header">
{{iconOrImage data}}
<span class="badge-display-name">{{data.name}}</span>
</h2>
<form.Field
@name="enabled"
@validation="required"
@title={{i18n "admin.badges.status"}}
as |field|
>
<field.Question
@yesLabel={{i18n "admin.badges.enabled"}}
@noLabel={{i18n "admin.badges.disabled"}}
/>
</form.Field>
{{#if @controller.readOnly}}
<form.Container data-name="name" @title={{i18n "admin.badges.name"}}>
<span class="readonly-field">
{{@controller.model.name}}
</span>
<LinkTo
@route="adminSiteText"
@query={{hash
q=(concat @controller.textCustomizationPrefix "name")
}}
>
{{icon "pencil"}}
</LinkTo>
</form.Container>
{{else}}
<form.Field
@title={{i18n "admin.badges.name"}}
@name="name"
@disabled={{@controller.readOnly}}
@validation="required"
as |field|
>
<field.Input />
</form.Field>
{{/if}}
<form.Section @title="Design">
<form.Field
@name="badge_type_id"
@title={{i18n "admin.badges.badge_type"}}
@disabled={{@controller.readOnly}}
as |field|
>
<field.Select as |select|>
{{#each @controller.badgeTypes as |badgeType|}}
<select.Option @value={{badgeType.id}}>
{{badgeType.name}}
</select.Option>
{{/each}}
</field.Select>
</form.Field>
<form.ConditionalContent
@activeName={{if data.image_url "upload-image" "choose-icon"}}
as |cc|
>
<cc.Conditions as |Condition|>
<Condition @name="choose-icon">
{{i18n "admin.badges.select_an_icon"}}
</Condition>
<Condition @name="upload-image">
{{i18n "admin.badges.upload_an_image"}}
</Condition>
</cc.Conditions>
<cc.Contents as |Content|>
<Content @name="choose-icon">
<form.Field
@title={{i18n "admin.badges.icon"}}
@showTitle={{false}}
@name="icon"
@onSet={{@controller.onSetIcon}}
@format="small"
as |field|
>
<field.Icon />
</form.Field>
</Content>
<Content @name="upload-image">
<form.Field
@name="image_url"
@showTitle={{false}}
@title={{i18n "admin.badges.image"}}
@onSet={{@controller.onSetImage}}
@onUnset={{@controller.onUnsetImage}}
as |field|
>
<field.Image @type="badge_image" />
</form.Field>
</Content>
</cc.Contents>
</form.ConditionalContent>
{{#if @controller.readOnly}}
<form.Container
data-name="description"
@title={{i18n "admin.badges.description"}}
>
<span class="readonly-field">
{{@controller.model.description}}
</span>
<LinkTo
@route="adminSiteText"
@query={{hash
q=(concat @controller.textCustomizationPrefix "description")
}}
>
{{icon "pencil"}}
</LinkTo>
</form.Container>
{{else}}
<form.Field
@title={{i18n "admin.badges.description"}}
@name="description"
@disabled={{@controller.readOnly}}
as |field|
>
<field.Textarea />
</form.Field>
{{/if}}
{{#if @controller.readOnly}}
<form.Container
data-name="long_description"
@title={{i18n "admin.badges.long_description"}}
>
<span class="readonly-field">
{{@controller.model.long_description}}
</span>
<LinkTo
@route="adminSiteText"
@query={{hash
q=(concat
@controller.textCustomizationPrefix "long_description"
)
}}
>
{{icon "pencil"}}
</LinkTo>
</form.Container>
{{else}}
<form.Field
@name="long_description"
@title={{i18n "admin.badges.long_description"}}
@disabled={{@controller.readOnly}}
as |field|
>
<field.Textarea />
</form.Field>
{{/if}}
</form.Section>
{{#if @controller.siteSettings.enable_badge_sql}}
<form.Section @title="Query">
<form.Field
@name="query"
@title={{i18n "admin.badges.query"}}
@disabled={{@controller.readOnly}}
as |field|
>
<field.Code @lang="sql" />
</form.Field>
{{#if (@controller.hasQuery data.query)}}
<form.Container>
<form.Button
@isLoading={{@controller.preview_loading}}
@label="admin.badges.preview.link_text"
class="preview-badge"
@action={{fn @controller.showPreview data "false"}}
/>
<form.Button
@isLoading={{@controller.preview_loading}}
@label="admin.badges.preview.plan_text"
class="preview-badge-plan"
@action={{fn @controller.showPreview data "true"}}
/>
</form.Container>
<form.CheckboxGroup as |group|>
<group.Field
@name="auto_revoke"
@disabled={{@controller.readOnly}}
@showTitle={{false}}
@title={{i18n "admin.badges.auto_revoke"}}
as |field|
>
<field.Checkbox />
</group.Field>
<group.Field
@name="target_posts"
@disabled={{@controller.readOnly}}
@title={{i18n "admin.badges.target_posts"}}
@showTitle={{false}}
as |field|
>
<field.Checkbox />
</group.Field>
</form.CheckboxGroup>
<form.Field
@name="trigger"
@disabled={{@controller.readOnly}}
@validation="required"
@title={{i18n "admin.badges.trigger"}}
as |field|
>
<field.Select as |select|>
{{#each @controller.badgeTriggers as |badgeType|}}
<select.Option @value={{badgeType.id}}>
{{badgeType.name}}
</select.Option>
{{/each}}
</field.Select>
</form.Field>
{{/if}}
</form.Section>
{{/if}}
<form.Section @title="Settings">
<form.Field
@name="badge_grouping_id"
@disabled={{@controller.readOnly}}
@validation="required"
@title={{i18n "admin.badges.badge_grouping"}}
as |field|
>
<field.Menu
@selection={{@controller.currentBadgeGrouping data}}
as |menu|
>
{{#each @controller.badgeGroupings as |grouping|}}
<menu.Item @value={{grouping.id}}>{{grouping.name}}</menu.Item>
{{/each}}
<menu.Divider />
<menu.Item @action={{routeAction "editGroupings"}}>Add new group</menu.Item>
</field.Menu>
</form.Field>
<form.CheckboxGroup
@title={{i18n "admin.badges.usage_heading"}}
as |group|
>
<group.Field
@title={{i18n "admin.badges.allow_title"}}
@showTitle={{false}}
@name="allow_title"
@format="full"
as |field|
>
<field.Checkbox />
</group.Field>
<group.Field
@title={{i18n "admin.badges.multiple_grant"}}
@showTitle={{false}}
@name="multiple_grant"
@disabled={{@controller.readOnly}}
@format="full"
as |field|
>
<field.Checkbox />
</group.Field>
</form.CheckboxGroup>
<form.CheckboxGroup
@title={{i18n "admin.badges.visibility_heading"}}
as |group|
>
<group.Field
@title={{i18n "admin.badges.listable"}}
@showTitle={{false}}
@name="listable"
@disabled={{@controller.readOnly}}
@format="full"
as |field|
>
<field.Checkbox />
</group.Field>
<group.Field
@title={{i18n "admin.badges.show_posts"}}
@showTitle={{false}}
@name="show_posts"
@disabled={{@controller.readOnly}}
@format="full"
as |field|
>
<field.Checkbox />
</group.Field>
<group.Field
@title={{i18n "admin.badges.show_in_post_header"}}
@showTitle={{false}}
@name="show_in_post_header"
@disabled={{@controller.disableBadgeOnPosts data}}
@format="full"
as |field|
>
<field.Checkbox>
{{#if (@controller.postHeaderDescription data)}}
{{i18n "admin.badges.show_in_post_header_disabled"}}
{{/if}}
</field.Checkbox>
</group.Field>
</form.CheckboxGroup>
</form.Section>
<PluginOutlet
@name="admin-above-badge-buttons"
@outletArgs={{hash badge=@controller.buffered form=form}}
/>
<form.Actions>
<form.Submit />
{{#unless @controller.readOnly}}
<form.Button
@action={{@controller.handleDelete}}
class="badge-form__delete-badge-btn btn-danger"
>
{{i18n "admin.badges.delete"}}
</form.Button>
{{/unless}}
</form.Actions>
{{#if @controller.grant_count}}
<div class="content-body current-badge-actions">
<div>
<LinkTo @route="badges.show" @model={{@controller}}>
{{htmlSafe
(i18n
"badges.awarded"
count=@controller.displayCount
number=(number @controller.displayCount)
)
}}
</LinkTo>
</div>
</div>
{{/if}}
</Form>
<AdminBadgesShow @controller={{@controller}} @badge={{@model}} />
</template>
);

View File

@ -7,7 +7,15 @@ import getURL from "discourse/lib/get-url";
import BadgeGrouping from "discourse/models/badge-grouping";
import RestModel from "discourse/models/rest";
const DEFAULTS = {
enabled: true,
};
export default class Badge extends RestModel {
static create(args) {
return super.create({ ...args, ...DEFAULTS });
}
static createFromJson(json) {
// Create BadgeType objects.
const badgeTypes = {};

View File

@ -1,115 +0,0 @@
import { settled } from "@ember/test-helpers";
import { setupTest } from "ember-qunit";
import { module, test } from "qunit";
import sinon from "sinon";
import Badge from "discourse/models/badge";
import UserBadge from "discourse/models/user-badge";
module("Unit | Controller | admin-user-badges", function (hooks) {
setupTest(hooks);
test("availableBadges", function (assert) {
const badgeFirst = Badge.create({
id: 3,
name: "A Badge",
enabled: true,
manually_grantable: true,
});
const badgeMiddle = Badge.create({
id: 1,
name: "My Badge",
enabled: true,
manually_grantable: true,
});
const badgeLast = Badge.create({
id: 2,
name: "Zoo Badge",
enabled: true,
manually_grantable: true,
});
const badgeDisabled = Badge.create({
id: 4,
name: "Disabled Badge",
enabled: false,
manually_grantable: true,
});
const badgeAutomatic = Badge.create({
id: 5,
name: "Automatic Badge",
enabled: true,
manually_grantable: false,
});
const controller = this.owner.lookup("controller:admin-user-badges");
controller.setProperties({
model: [],
badges: [
badgeLast,
badgeFirst,
badgeMiddle,
badgeDisabled,
badgeAutomatic,
],
});
const sortedNames = [badgeFirst.name, badgeMiddle.name, badgeLast.name];
const badgeNames = controller.availableBadges.map((badge) => badge.name);
assert.false(
badgeNames.includes(badgeDisabled),
"excludes disabled badges"
);
assert.deepEqual(badgeNames, sortedNames, "sorts badges by name");
});
test("performGrantBadge", async function (assert) {
const GrantBadgeStub = sinon.stub(UserBadge, "grant");
const controller = this.owner.lookup("controller:admin-user-badges");
const store = this.owner.lookup("service:store");
const badgeToGrant = store.createRecord("badge", {
id: 3,
name: "Granted Badge",
enabled: true,
manually_grantable: true,
});
const otherBadge = store.createRecord("badge", {
id: 4,
name: "Other Badge",
enabled: true,
manually_grantable: true,
});
const badgeReason = "Test Reason";
const user = { username: "jb", name: "jack black", id: 42 };
controller.setProperties({
model: [],
adminUser: { model: user },
badgeReason,
selectedBadgeId: badgeToGrant.id,
badges: [badgeToGrant, otherBadge],
});
const newUserBadge = store.createRecord("badge", {
id: 88,
badge_id: badgeToGrant.id,
user_id: user.id,
});
GrantBadgeStub.returns(Promise.resolve(newUserBadge));
controller.performGrantBadge();
await settled();
assert.true(
GrantBadgeStub.calledWith(badgeToGrant.id, user.username, badgeReason)
);
assert.strictEqual(controller.badgeReason, "");
assert.strictEqual(controller.userBadges.length, 1);
assert.strictEqual(controller.userBadges[0].id, newUserBadge.id);
assert.strictEqual(controller.selectedBadgeId, otherBadge.id);
});
});

View File

@ -1,126 +0,0 @@
import { getOwner } from "@ember/owner";
import { setupTest } from "ember-qunit";
import { module, test } from "qunit";
import {
grantableBadges,
isBadgeGrantable,
} from "discourse/lib/grant-badge-utils";
module("Unit | Utility | Grant Badge", function (hooks) {
setupTest(hooks);
test("grantableBadges", function (assert) {
const store = getOwner(this).lookup("service:store");
const firstBadge = store.createRecord("badge", {
id: 3,
name: "A Badge",
enabled: true,
manually_grantable: true,
});
const middleBadge = store.createRecord("badge", {
id: 1,
name: "My Badge",
enabled: true,
manually_grantable: true,
});
const lastBadge = store.createRecord("badge", {
id: 2,
name: "Zoo Badge",
enabled: true,
manually_grantable: true,
multiple_grant: true,
});
const grantedBadge = store.createRecord("badge", {
id: 6,
name: "Grant Badge",
enabled: true,
manually_grantable: true,
multiple_grant: false,
});
const disabledBadge = store.createRecord("badge", {
id: 4,
name: "Disabled Badge",
enabled: false,
manually_grantable: true,
});
const automaticBadge = store.createRecord("badge", {
id: 5,
name: "Automatic Badge",
enabled: true,
manually_grantable: false,
});
const allBadges = [
lastBadge,
firstBadge,
middleBadge,
grantedBadge,
disabledBadge,
automaticBadge,
];
const userBadges = [lastBadge, grantedBadge].map((badge) => {
return store.createRecord("user-badge", {
badge_id: badge.id,
});
});
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.false(
badgeNames.includes(grantedBadge.name),
"excludes already granted badges"
);
assert.false(
badgeNames.includes(disabledBadge.name),
"excludes disabled badges"
);
assert.false(
badgeNames.includes(automaticBadge.name),
"excludes automatic badges"
);
assert.true(
badgeNames.includes(lastBadge.name),
"includes granted badges that can be granted multiple times"
);
});
test("isBadgeGrantable", function (assert) {
const store = getOwner(this).lookup("service:store");
const grantable_once_badge = store.createRecord("badge", {
id: 3,
name: "A Badge",
enabled: true,
manually_grantable: true,
});
const other_grantable_badge = store.createRecord("badge", {
id: 2,
name: "Zoo Badge",
enabled: true,
manually_grantable: true,
multiple_grant: true,
});
const disabledBadge = store.createRecord("badge", {
id: 4,
name: "Disabled Badge",
enabled: false,
manually_grantable: true,
});
const badges = [grantable_once_badge, other_grantable_badge];
assert.true(isBadgeGrantable(grantable_once_badge.id, badges));
assert.false(
isBadgeGrantable(disabledBadge.id, badges),
"returns false when badgeId is not that of any badge in availableBadges"
);
assert.false(
isBadgeGrantable(grantable_once_badge.id, []),
"returns false if empty array availableBadges is passed in"
);
assert.false(
isBadgeGrantable(grantable_once_badge.id, null),
"returns false if no availableBadges is defined"
);
});
});

View File

@ -4,6 +4,7 @@ class SiteSetting < ActiveRecord::Base
VALID_AREAS = %w[
about
analytics
badges
categories_and_tags
email
embedding

View File

@ -397,9 +397,11 @@ basic:
enable_badges:
client: true
default: true
area: "badges"
show_badges_in_post_header:
client: true
default: true
area: "badges"
enable_badge_sql:
client: true
default: false
@ -409,6 +411,7 @@ basic:
default: 2
min: 0
max: 6
area: "badges"
whispers_allowed_groups:
type: group_list
list_type: compact