mirror of
https://github.com/discourse/discourse.git
synced 2025-04-24 22:24:26 +08:00
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:
parent
1e0d773b54
commit
e8997b6202
@ -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>
|
||||
}
|
@ -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>
|
||||
}
|
@ -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>
|
||||
}
|
@ -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>
|
||||
}
|
@ -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>
|
||||
}
|
@ -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 {}
|
||||
|
@ -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 {}
|
||||
|
@ -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 {}
|
||||
|
@ -0,0 +1,3 @@
|
||||
import AdminAreaSettingsBaseController from "admin/controllers/admin-area-settings-base";
|
||||
|
||||
export default class AdminBadgesSettingsController extends AdminAreaSettingsBaseController {}
|
@ -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 {}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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");
|
||||
}
|
||||
}
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
@ -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" });
|
||||
}
|
||||
|
60
app/assets/javascripts/admin/addon/services/admin-badges.js
Normal file
60
app/assets/javascripts/admin/addon/services/admin-badges.js
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
@ -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>);
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>);
|
||||
|
@ -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>
|
||||
);
|
@ -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>
|
||||
);
|
||||
|
@ -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 = {};
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
@ -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"
|
||||
);
|
||||
});
|
||||
});
|
@ -4,6 +4,7 @@ class SiteSetting < ActiveRecord::Base
|
||||
VALID_AREAS = %w[
|
||||
about
|
||||
analytics
|
||||
badges
|
||||
categories_and_tags
|
||||
email
|
||||
embedding
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user