From 83c549bd317d7fd91a1161bd887d9ca1cf3c3654 Mon Sep 17 00:00:00 2001 From: Kyle Zhao Date: Sun, 21 Jan 2018 22:10:53 -0500 Subject: [PATCH] FEATURE: grant badges in post admin wrench (#5498) * FEATURE: grant badges in post admin wrench * only grant manually grantable badges * extract GrantBadgeController mixin --- .../controllers/admin-user-badges.js.es6 | 64 ++++++------------- .../admin/templates/user-badges.hbs | 4 +- app/assets/javascripts/application.js | 2 +- .../discourse/controllers/grant-badge.js.es6 | 63 ++++++++++++++++++ .../discourse/controllers/topic.js.es6 | 5 ++ .../mixins/grant-badge-controller.js.es6 | 38 +++++++++++ .../javascripts/discourse/routes/topic.js.es6 | 4 ++ .../discourse/templates/modal/grant-badge.hbs | 15 +++++ .../javascripts/discourse/templates/topic.hbs | 1 + .../discourse/widgets/post-admin-menu.js.es6 | 7 ++ app/models/badge.rb | 4 ++ app/serializers/badge_serializer.rb | 2 +- config/locales/client.en.yml | 2 + spec/models/badge_spec.rb | 19 ++++++ .../controllers/admin-user-badges-test.js.es6 | 16 +++-- .../mixins/grant-badge-controller-test.js.es6 | 38 +++++++++++ 16 files changed, 229 insertions(+), 55 deletions(-) create mode 100644 app/assets/javascripts/discourse/controllers/grant-badge.js.es6 create mode 100644 app/assets/javascripts/discourse/mixins/grant-badge-controller.js.es6 create mode 100644 app/assets/javascripts/discourse/templates/modal/grant-badge.hbs create mode 100644 test/javascripts/mixins/grant-badge-controller-test.js.es6 diff --git a/app/assets/javascripts/admin/controllers/admin-user-badges.js.es6 b/app/assets/javascripts/admin/controllers/admin-user-badges.js.es6 index a2b7c94f175..346bc273c16 100644 --- a/app/assets/javascripts/admin/controllers/admin-user-badges.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-user-badges.js.es6 @@ -1,8 +1,10 @@ -import UserBadge from 'discourse/models/user-badge'; +import GrantBadgeController from "discourse/mixins/grant-badge-controller"; -export default Ember.Controller.extend({ +export default Ember.Controller.extend(GrantBadgeController, { adminUser: Ember.inject.controller(), user: Ember.computed.alias('adminUser.model'), + userBadges: Ember.computed.alias('model'), + allBadges: Ember.computed.alias('badges'), sortedBadges: Ember.computed.sort('model', 'badgeSortOrder'), badgeSortOrder: ['granted_at:desc'], @@ -41,36 +43,6 @@ export default Ember.Controller.extend({ return _(expanded).sortBy(group => group.granted_at).reverse().value(); }.property('model', 'model.[]', 'model.expandedBadges.[]'), - /** - Array of badges that have not been granted to this user. - - @property grantableBadges - @type {Boolean} - **/ - grantableBadges: function() { - var granted = {}; - this.get('model').forEach(function(userBadge) { - granted[userBadge.get('badge_id')] = true; - }); - - var badges = []; - this.get('badges').forEach(function(badge) { - if (badge.get('enabled') && (badge.get('multiple_grant') || !granted[badge.get('id')])) { - badges.push(badge); - } - }); - - return _.sortBy(badges, badge => badge.get('name')); - }.property('badges.[]', 'model.[]'), - - /** - Whether there are any badges that can be granted. - - @property noBadges - @type {Boolean} - **/ - noBadges: Em.computed.empty('grantableBadges'), - actions: { expandGroup: function(userBadge){ @@ -79,21 +51,21 @@ export default Ember.Controller.extend({ model.get('expandedBadges').pushObject(userBadge.badge.id); }, - grantBadge(badgeId) { - UserBadge.grant(badgeId, this.get('user.username'), this.get('badgeReason')).then(userBadge => { - this.set('badgeReason', ''); - this.get('model').pushObject(userBadge); - Ember.run.next(() => { - // Update the selected badge ID after the combobox has re-rendered. - const newSelectedBadge = this.get('grantableBadges')[0]; - if (newSelectedBadge) { - this.set('selectedBadgeId', newSelectedBadge.get('id')); - } + grantBadge() { + this.grantBadge(this.get('selectedBadgeId'), this.get('user.username'), this.get('badgeReason')) + .then(() => { + this.set('badgeReason', ''); + Ember.run.next(() => { + // Update the selected badge ID after the combobox has re-rendered. + const newSelectedBadge = this.get('grantableBadges')[0]; + if (newSelectedBadge) { + this.set('selectedBadgeId', newSelectedBadge.get('id')); + } + }); + }, function() { + // Failure + bootbox.alert(I18n.t('generic_error')); }); - }, function() { - // Failure - bootbox.alert(I18n.t('generic_error')); - }); }, revokeBadge(userBadge) { diff --git a/app/assets/javascripts/admin/templates/user-badges.hbs b/app/assets/javascripts/admin/templates/user-badges.hbs index 1f6bae617c9..167f4ffa1d6 100644 --- a/app/assets/javascripts/admin/templates/user-badges.hbs +++ b/app/assets/javascripts/admin/templates/user-badges.hbs @@ -10,7 +10,7 @@

{{i18n 'admin.badges.grant_badge'}}


- {{#if noBadges}} + {{#if noGrantableBadges}}

{{i18n 'admin.badges.no_badges'}}

{{else}}
@@ -22,7 +22,7 @@ {{input type="text" value=badgeReason}}
{{i18n 'admin.badges.reason_help'}} - +
{{/if}} diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 6b0d64be848..02bc257b12b 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -60,9 +60,9 @@ //= require ./discourse/models/user-action //= require ./discourse/models/draft //= require ./discourse/models/composer +//= require ./discourse/models/user-badge //= require_tree ./discourse/mixins //= require ./discourse/models/invite -//= require ./discourse/models/user-badge //= require ./discourse/controllers/discovery-sortable //= require ./discourse/controllers/navigation/default //= require ./discourse/components/edit-category-panel diff --git a/app/assets/javascripts/discourse/controllers/grant-badge.js.es6 b/app/assets/javascripts/discourse/controllers/grant-badge.js.es6 new file mode 100644 index 00000000000..8a33d053ff2 --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/grant-badge.js.es6 @@ -0,0 +1,63 @@ +import computed from "ember-addons/ember-computed-decorators"; +import { extractError } from 'discourse/lib/ajax-error'; +import ModalFunctionality from "discourse/mixins/modal-functionality"; +import GrantBadgeController from "discourse/mixins/grant-badge-controller"; +import Badge from 'discourse/models/badge'; +import UserBadge from 'discourse/models/user-badge'; + +export default Ember.Controller.extend(ModalFunctionality, GrantBadgeController, { + topicController: Ember.inject.controller("topic"), + loading: true, + saving: false, + selectedBadgeId: null, + allBadges: [], + userBadges: [], + + @computed('topicController.selectedPosts') + post() { + return this.get('topicController.selectedPosts')[0]; + }, + + @computed('post') + badgeReason(post) { + const url = post.get('url'); + const protocolAndHost = window.location.protocol + '//' + window.location.host; + + return url.indexOf('/') === 0 ? protocolAndHost + url : url; + }, + + @computed("saving", "selectedBadgeGrantable") + buttonDisabled(saving, selectedBadgeGrantable) { + return saving || !selectedBadgeGrantable; + }, + + onShow() { + this.set('loading', true); + + Ember.RSVP.all([Badge.findAll(), UserBadge.findByUsername(this.get('post.username'))]) + .then(([allBadges, userBadges]) => { + this.setProperties({ + 'allBadges': allBadges, + 'userBadges': userBadges, + 'loading': false, + }); + }); + }, + + actions: { + grantBadge() { + this.set('saving', true); + + this.grantBadge(this.get('selectedBadgeId'), this.get('post.username'), this.get('badgeReason')) + .then(newBadge => { + this.set('selectedBadgeId', null); + this.flash(I18n.t( + 'badges.successfully_granted', { username: this.get('post.username'), badge: newBadge.get('badge.name') } + ), 'success'); + }, error => { + this.flash(extractError(error), 'error'); + }) + .finally(() => this.set('saving', false)); + } + } +}); diff --git a/app/assets/javascripts/discourse/controllers/topic.js.es6 b/app/assets/javascripts/discourse/controllers/topic.js.es6 index 80209d7b731..e47309c7576 100644 --- a/app/assets/javascripts/discourse/controllers/topic.js.es6 +++ b/app/assets/javascripts/discourse/controllers/topic.js.es6 @@ -518,6 +518,11 @@ export default Ember.Controller.extend(BufferedContent, { this.send('changeOwner'); }, + grantBadge(post) { + this.set("selectedPostIds", [post.id]); + this.send('showGrantBadgeModal'); + }, + toggleParticipant(user) { this.get("model.postStream") .toggleParticipant(user.get("username")) diff --git a/app/assets/javascripts/discourse/mixins/grant-badge-controller.js.es6 b/app/assets/javascripts/discourse/mixins/grant-badge-controller.js.es6 new file mode 100644 index 00000000000..6000f041917 --- /dev/null +++ b/app/assets/javascripts/discourse/mixins/grant-badge-controller.js.es6 @@ -0,0 +1,38 @@ +import computed from "ember-addons/ember-computed-decorators"; +import UserBadge from 'discourse/models/user-badge'; + +export default Ember.Mixin.create({ + @computed('allBadges.[]', 'userBadges.[]') + grantableBadges(allBadges, userBadges) { + const granted = userBadges.reduce((map, badge) => { + map[badge.get('badge_id')] = true; + return map; + }, {}); + + return allBadges + .filter(badge => { + return badge.get('enabled') + && badge.get('manually_grantable') + && (!granted[badge.get('id')] || badge.get('multiple_grant')); + }) + .sort((a, b) => a.get('name').localeCompare(b.get('name'))); + }, + + noGrantableBadges: Ember.computed.empty('grantableBadges'), + + @computed('selectedBadgeId', 'grantableBadges') + selectedBadgeGrantable(selectedBadgeId, grantableBadges) { + return grantableBadges && grantableBadges.find(badge => badge.get('id') === selectedBadgeId); + }, + + grantBadge(selectedBadgeId, username, badgeReason) { + return UserBadge.grant(selectedBadgeId, username, badgeReason) + .then(newBadge => { + this.get('userBadges').pushObject(newBadge); + return newBadge; + }, error => { + throw error; + }); + } +}); + diff --git a/app/assets/javascripts/discourse/routes/topic.js.es6 b/app/assets/javascripts/discourse/routes/topic.js.es6 index 9cc709d1979..281adb45ff5 100644 --- a/app/assets/javascripts/discourse/routes/topic.js.es6 +++ b/app/assets/javascripts/discourse/routes/topic.js.es6 @@ -85,6 +85,10 @@ const TopicRoute = Discourse.Route.extend({ this.controllerFor('modal').set('modalClass', 'history-modal'); }, + showGrantBadgeModal() { + showModal('grant-badge', { model: this.modelFor('topic'), title: 'admin.badges.grant_badge' }); + }, + showRawEmail(model) { showModal('raw-email', { model }); this.controllerFor('raw_email').loadRawEmail(model.get("id")); diff --git a/app/assets/javascripts/discourse/templates/modal/grant-badge.hbs b/app/assets/javascripts/discourse/templates/modal/grant-badge.hbs new file mode 100644 index 00000000000..eadced1c9c9 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/modal/grant-badge.hbs @@ -0,0 +1,15 @@ +{{#d-modal-body class='grant-badge'}} + {{#conditional-loading-spinner condition=loading}} + {{#if noGrantableBadges}} +

{{i18n 'admin.badges.no_badges'}}

+ {{else}} +

{{combo-box filterable=true value=selectedBadgeId content=grantableBadges none="badges.none"}}

+ {{/if}} + {{/conditional-loading-spinner}} +{{/d-modal-body}} + + diff --git a/app/assets/javascripts/discourse/templates/topic.hbs b/app/assets/javascripts/discourse/templates/topic.hbs index 28a0cf5fdc8..5bdaca85fbe 100644 --- a/app/assets/javascripts/discourse/templates/topic.hbs +++ b/app/assets/javascripts/discourse/templates/topic.hbs @@ -170,6 +170,7 @@ togglePostType=(action "togglePostType") rebakePost=(action "rebakePost") changePostOwner=(action "changePostOwner") + grantBadge=(action "grantBadge") unhidePost=(action "unhidePost") replyToPost=(action "replyToPost") toggleWiki=(action "toggleWiki") diff --git a/app/assets/javascripts/discourse/widgets/post-admin-menu.js.es6 b/app/assets/javascripts/discourse/widgets/post-admin-menu.js.es6 index 484935a7a92..a1c4325bd40 100644 --- a/app/assets/javascripts/discourse/widgets/post-admin-menu.js.es6 +++ b/app/assets/javascripts/discourse/widgets/post-admin-menu.js.es6 @@ -64,6 +64,13 @@ export function buildManageButtons(attrs, currentUser) { action: 'changePostOwner', className: 'change-owner' }); + + contents.push({ + icon: 'certificate', + label: 'post.controls.grant_badge', + action: 'grantBadge', + className: 'grant-badge' + }); } if (attrs.canManage || attrs.canWiki) { diff --git a/app/models/badge.rb b/app/models/badge.rb index a2f17409a7b..abb618cc785 100644 --- a/app/models/badge.rb +++ b/app/models/badge.rb @@ -219,6 +219,10 @@ class Badge < ActiveRecord::Base Slug.for(self.display_name, '-') end + def manually_grantable? + query.blank? && !system? + end + protected def ensure_not_system diff --git a/app/serializers/badge_serializer.rb b/app/serializers/badge_serializer.rb index d3bbd873390..3f7929b328c 100644 --- a/app/serializers/badge_serializer.rb +++ b/app/serializers/badge_serializer.rb @@ -1,7 +1,7 @@ class BadgeSerializer < ApplicationSerializer attributes :id, :name, :description, :grant_count, :allow_title, :multiple_grant, :icon, :image, :listable, :enabled, :badge_grouping_id, - :system, :long_description, :slug, :has_badge + :system, :long_description, :slug, :has_badge, :manually_grantable? has_one :badge_type diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 6762f2ffa18..d3c2e529daf 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1979,6 +1979,7 @@ en: rebake: "Rebuild HTML" unhide: "Unhide" change_owner: "Change Ownership" + grant_badge: "Grant Badge" actions: flag: 'Flag' @@ -2487,6 +2488,7 @@ en: other: "%{count} granted" select_badge_for_title: Select a badge to use as your title none: "(none)" + successfully_granted: "Successfully granted %{badge} to %{username}" badge_grouping: getting_started: name: Getting Started diff --git a/spec/models/badge_spec.rb b/spec/models/badge_spec.rb index f6f8b0c0d79..cbac526cb7f 100644 --- a/spec/models/badge_spec.rb +++ b/spec/models/badge_spec.rb @@ -61,4 +61,23 @@ describe Badge do expect(b.grant_count).to eq(1) end + describe '#manually_grantable?' do + let(:badge) { Fabricate(:badge, name: 'Test Badge') } + subject { badge.manually_grantable? } + + context 'when system badge' do + before { badge.system = true } + it { is_expected.to be false } + end + + context 'when has query' do + before { badge.query = 'SELECT id FROM users' } + it { is_expected.to be false } + end + + context 'when neither system nor has query' do + before { badge.update_columns(system: false, query: nil) } + it { is_expected.to be true } + end + end end diff --git a/test/javascripts/admin/controllers/admin-user-badges-test.js.es6 b/test/javascripts/admin/controllers/admin-user-badges-test.js.es6 index 112ef64d041..763644eaddb 100644 --- a/test/javascripts/admin/controllers/admin-user-badges-test.js.es6 +++ b/test/javascripts/admin/controllers/admin-user-badges-test.js.es6 @@ -9,11 +9,17 @@ moduleFor('controller:admin-user-badges', { }); QUnit.test("grantableBadges", function(assert) { - const badgeFirst = Badge.create({id: 3, name: "A Badge", enabled: true}); - const badgeMiddle = Badge.create({id: 1, name: "My Badge", enabled: true}); - const badgeLast = Badge.create({id: 2, name: "Zoo Badge", enabled: true}); - const badgeDisabled = Badge.create({id: 4, name: "Disabled Badge", enabled: false}); - const controller = this.subject({ model: [], badges: [badgeLast, badgeFirst, badgeMiddle, badgeDisabled] }); + 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.subject({ + model: [], + badges: [badgeLast, badgeFirst, badgeMiddle, badgeDisabled, badgeAutomatic] + }); + const sortedNames = [badgeFirst.name, badgeMiddle.name, badgeLast.name]; const badgeNames = controller.get('grantableBadges').map(function(badge) { return badge.name; diff --git a/test/javascripts/mixins/grant-badge-controller-test.js.es6 b/test/javascripts/mixins/grant-badge-controller-test.js.es6 new file mode 100644 index 00000000000..d1e34cd133a --- /dev/null +++ b/test/javascripts/mixins/grant-badge-controller-test.js.es6 @@ -0,0 +1,38 @@ +import GrantBadgeControllerMixin from 'discourse/mixins/grant-badge-controller'; +import Badge from 'discourse/models/badge'; + +QUnit.module('mixin:grant-badge-controller', { + before: function() { + this.GrantBadgeController = Ember.Controller.extend(GrantBadgeControllerMixin); + + this.badgeFirst = Badge.create({ id: 3, name: 'A Badge', enabled: true, manually_grantable: true }); + this.badgeMiddle = Badge.create({ id: 1, name: 'My Badge', enabled: true, manually_grantable: true }); + this.badgeLast = Badge.create({ id: 2, name: 'Zoo Badge', enabled: true, manually_grantable: true }); + this.badgeDisabled = Badge.create({ id: 4, name: 'Disabled Badge', enabled: false, manually_grantable: true }); + this.badgeAutomatic = Badge.create({ id: 5, name: 'Automatic Badge', enabled: true, manually_grantable: false }); + }, + + beforeEach: function() { + this.subject = this.GrantBadgeController.create({ + userBadges: [], + allBadges: [this.badgeLast, this.badgeFirst, this.badgeMiddle, this.badgeDisabled, this.badgeAutomatic], + }); + } +}); + +QUnit.test('grantableBadges', function(assert) { + const sortedNames = [this.badgeFirst.name, this.badgeMiddle.name, this.badgeLast.name]; + const badgeNames = this.subject.get('grantableBadges').map(badge => badge.name); + + assert.not(badgeNames.includes(this.badgeDisabled), 'excludes disabled badges'); + assert.not(badgeNames.includes(this.badgeAutomatic), 'excludes automatic badges'); + assert.deepEqual(badgeNames, sortedNames, 'sorts badges by name'); +}); + +QUnit.test('selectedBadgeGrantable', function(assert) { + this.subject.set('selectedBadgeId', this.badgeDisabled.id); + assert.not(this.subject.get('selectedBadgeGrantable')); + + this.subject.set('selectedBadgeId', this.badgeFirst.id); + assert.ok(this.subject.get('selectedBadgeGrantable')); +});