FEATURE: Add option to grant badge multiple times to users using Bulk Award (#13571)

FEATURE: Add option to grant badge multiple times to users using Bulk Award (#13571)

Currently when bulk-awarding a badge that can be granted multiple times, users in the CSV file are granted the badge once no matter how many times they’re listed in the file and only if they don’t have the badge already.

This PR adds a new option to the Badge Bulk Award feature so that it’s possible to grant users a badge even if they already have the badge and as many times as they appear in the CSV file.

diff --git a/app/assets/javascripts/admin/addon/controllers/admin-badges-award.js b/app/assets/javascripts/admin/addon/controllers/admin-badges-award.js
index 218b3cb..4318575 100644
--- a/app/assets/javascripts/admin/addon/controllers/admin-badges-award.js
+++ b/app/assets/javascripts/admin/addon/controllers/admin-badges-award.js
@@ -2,38 +2,98 @@ import Controller from "@ember/controller";
 import I18n from "I18n";
 import { ajax } from "discourse/lib/ajax";
 import bootbox from "bootbox";
-import { popupAjaxError } from "discourse/lib/ajax-error";
+import { extractError } from "discourse/lib/ajax-error";
+import { action } from "@ember/object";
+import discourseComputed from "discourse-common/utils/decorators";
 
 export default Controller.extend({
   saving: false,
   replaceBadgeOwners: false,
+  grantExistingHolders: false,
+  fileSelected: false,
+  unmatchedEntries: null,
+  resultsMessage: null,
+  success: false,
+  unmatchedEntriesCount: 0,
 
-  actions: {
-    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);
-
-        this.set("saving", true);
-
-        ajax(`/admin/badges/award/${this.model.id}`, options)
-          .then(() => {
-            bootbox.alert(I18n.t("admin.badges.mass_award.success"));
-          })
-          .catch(popupAjaxError)
-          .finally(() => this.set("saving", false));
-      } else {
-        bootbox.alert(I18n.t("admin.badges.mass_award.aborted"));
-      }
-    },
+  resetState() {
+    this.setProperties({
+      saving: false,
+      unmatchedEntries: null,
+      resultsMessage: null,
+      success: false,
+      unmatchedEntriesCount: 0,
+    });
+    this.send("updateFileSelected");
+  },
+
+  @discourseComputed("fileSelected", "saving")
+  massAwardButtonDisabled(fileSelected, saving) {
+    return !fileSelected || saving;
+  },
+
+  @discourseComputed("unmatchedEntriesCount", "unmatchedEntries.length")
+  unmatchedEntriesTruncated(unmatchedEntriesCount, length) {
+    return unmatchedEntriesCount && length && unmatchedEntriesCount > length;
+  },
+
+  @action
+  updateFileSelected() {
+    this.set(
+      "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.set("saving", true);
+
+      ajax(`/admin/badges/award/${this.model.id}`, options)
+        .then(
+          ({
+            matched_users_count: matchedCount,
+            unmatched_entries: unmatchedEntries,
+            unmatched_entries_count: unmatchedEntriesCount,
+          }) => {
+            this.setProperties({
+              resultsMessage: I18n.t("admin.badges.mass_award.success", {
+                count: matchedCount,
+              }),
+              success: true,
+            });
+            if (unmatchedEntries.length) {
+              this.setProperties({
+                unmatchedEntries,
+                unmatchedEntriesCount,
+              });
+            }
+          }
+        )
+        .catch((error) => {
+          this.setProperties({
+            resultsMessage: extractError(error),
+            success: false,
+          });
+        })
+        .finally(() => this.set("saving", false));
+    } else {
+      bootbox.alert(I18n.t("admin.badges.mass_award.aborted"));
+    }
   },
 });
diff --git a/app/assets/javascripts/admin/addon/routes/admin-badges-award.js b/app/assets/javascripts/admin/addon/routes/admin-badges-award.js
index ee6cf4b..6fe72a6 100644
--- a/app/assets/javascripts/admin/addon/routes/admin-badges-award.js
+++ b/app/assets/javascripts/admin/addon/routes/admin-badges-award.js
@@ -9,4 +9,9 @@ export default Route.extend({
       );
     }
   },
+
+  setupController(controller) {
+    this._super(...arguments);
+    controller.resetState();
+  },
 });
diff --git a/app/assets/javascripts/admin/addon/templates/badges-award.hbs b/app/assets/javascripts/admin/addon/templates/badges-award.hbs
index bb6c4bf..52bed37 100644
--- a/app/assets/javascripts/admin/addon/templates/badges-award.hbs
+++ b/app/assets/javascripts/admin/addon/templates/badges-award.hbs
@@ -14,25 +14,62 @@
       </div>
       <div>
         <h4>{{i18n "admin.badges.mass_award.upload_csv"}}</h4>
-        <input type="file" id="massAwardCSVUpload" accept=".csv">
+        <input type="file" id="massAwardCSVUpload" accept=".csv" onchange={{action "updateFileSelected"}}>
       </div>
       <div>
         <label>
           {{input type="checkbox" checked=replaceBadgeOwners}}
           {{i18n "admin.badges.mass_award.replace_owners"}}
         </label>
+        {{#if model.multiple_grant}}
+          <label class="grant-existing-holders">
+            {{input type="checkbox" checked=grantExistingHolders class="grant-existing-holders-checkbox"}}
+            {{i18n "admin.badges.mass_award.grant_existing_holders"}}
+          </label>
+        {{/if}}
       </div>
       {{d-button
           class="btn-primary"
           action=(action "massAward")
           type="submit"
-          disabled=saving
+          disabled=massAwardButtonDisabled
+          icon="certificate"
           label="admin.badges.mass_award.perform"}}
-      {{#link-to "adminBadges.index" class="btn btn-danger"}}
+      {{#link-to "adminBadges.index" class="btn btn-normal"}}
         {{d-icon "times"}}
         <span>{{i18n "cancel"}}</span>
       {{/link-to}}
     </form>
+    {{#if saving}}
+      {{i18n "uploading"}}
+    {{/if}}
+    {{#if resultsMessage}}
+      <p>
+        {{#if success}}
+          {{d-icon "check" class="bulk-award-status-icon success"}}
+        {{else}}
+          {{d-icon "times" class="bulk-award-status-icon failure"}}
+        {{/if}}
+        {{resultsMessage}}
+      </p>
+      {{#if unmatchedEntries.length}}
+        <p>
+          {{d-icon "exclamation-triangle" class="bulk-award-status-icon failure"}}
+          <span>
+            {{#if unmatchedEntriesTruncated}}
+              {{i18n "admin.badges.mass_award.csv_has_unmatched_users_truncated_list" count=unmatchedEntriesCount}}
+            {{else}}
+              {{i18n "admin.badges.mass_award.csv_has_unmatched_users"}}
+            {{/if}}
+          </span>
+        </p>
+        <ul>
+          {{#each 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}}
diff --git a/app/assets/javascripts/discourse/tests/acceptance/admin-badges-award-test.js b/app/assets/javascripts/discourse/tests/acceptance/admin-badges-award-test.js
new file mode 100644
index 0000000..c2c6e66
--- /dev/null
+++ b/app/assets/javascripts/discourse/tests/acceptance/admin-badges-award-test.js
@@ -0,0 +1,32 @@
+import {
+  acceptance,
+  exists,
+  query,
+} from "discourse/tests/helpers/qunit-helpers";
+import { click, visit } from "@ember/test-helpers";
+import { test } from "qunit";
+import I18n from "I18n";
+
+acceptance("Admin - Badges - Mass Award", function (needs) {
+  needs.user();
+  test("when the badge can be granted multiple times", async function (assert) {

[... diff too long, it was truncated ...]

GitHub sha: 31aa701518788fd3e08f5ec9f155e9a3fbe6b9a5

This commit appears in #13571 which was approved by tgxworld. It was merged by OsamaSayegh.