DEV: Modernize topic-bulk-actions (#22186)

Introduces new plugin api for adding bulk topic actions:

Example:

```js
api.addBulkActionButton({
  label: "super_plugin.bulk.enhance",
  icon: "magic",
  class: "btn-default",
  visible: ({ currentUser, siteSettings }) => siteSettings.super_plugin_enabled && currentUser.staff,
  async action({ setComponent }) {
    await doSomething(this.model.topics);
    setComponent(MyBulkModal);
  },
});
```
This commit is contained in:
Jarek Radosz 2023-07-18 20:10:16 +02:00 committed by GitHub
parent 3c69570b75
commit 2a96064e6b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 584 additions and 499 deletions

View File

@ -0,0 +1,9 @@
<p>{{i18n "topics.bulk.choose_append_tags"}}</p>
<p><TagChooser @tags={{this.tags}} @categoryId={{@categoryId}} /></p>
<DButton
@action={{fn @performAndRefresh (hash type="append_tags" tags=this.tags)}}
@disabled={{not this.tags}}
@label="topics.bulk.append_tags"
/>

View File

@ -0,0 +1,6 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
export default class AppendTags extends Component {
@tracked tags = [];
}

View File

@ -0,0 +1,15 @@
<p>{{i18n "topics.bulk.choose_new_category"}}</p>
<p>
<CategoryChooser
@value={{this.categoryId}}
@onChange={{action (mut this.categoryId)}}
/>
</p>
<ConditionalLoadingSpinner @condition={{@loading}}>
<DButton
@action={{this.changeCategory}}
@label="topics.bulk.change_category"
/>
</ConditionalLoadingSpinner>

View File

@ -0,0 +1,17 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
export default class ChangeCategory extends Component {
categoryId = 0;
@action
async changeCategory() {
await this.args.forEachPerformed(
{
type: "change_category",
category_id: this.categoryId,
},
(t) => t.set("category_id", this.categoryId)
);
}
}

View File

@ -0,0 +1,9 @@
<p>{{i18n "topics.bulk.choose_new_tags"}}</p>
<p><TagChooser @tags={{this.tags}} @categoryId={{@categoryId}} /></p>
<DButton
@action={{fn @performAndRefresh (hash type="change_tags" tags=this.tags)}}
@disabled={{not this.tags}}
@label="topics.bulk.change_tags"
/>

View File

@ -0,0 +1,6 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
export default class ChangeTags extends Component {
@tracked tags = [];
}

View File

@ -16,6 +16,6 @@
<DButton
@disabled={{this.disabled}}
@action={{action "changeNotificationLevel"}}
@action={{this.changeNotificationLevel}}
@label="topics.bulk.change_notification_level"
/>

View File

@ -0,0 +1,28 @@
import Component from "@glimmer/component";
import I18n from "I18n";
import { empty } from "@ember/object/computed";
import { topicLevels } from "discourse/lib/notification-levels";
import { action } from "@ember/object";
// Support for changing the notification level of various topics
export default class NotificationLevel extends Component {
notificationLevelId = null;
@empty("notificationLevelId") disabled;
get notificationLevels() {
return topicLevels.map((level) => ({
id: level.id.toString(),
name: I18n.t(`topic.notifications.${level.key}.title`),
description: I18n.t(`topic.notifications.${level.key}.description`),
}));
}
@action
changeNotificationLevel() {
this.args.performAndRefresh({
type: "change_notification_level",
notification_level_id: this.notificationLevelId,
});
}
}

View File

@ -1,33 +0,0 @@
import Controller, { inject as controller } from "@ember/controller";
import I18n from "I18n";
import discourseComputed from "discourse-common/utils/decorators";
import { empty } from "@ember/object/computed";
import { topicLevels } from "discourse/lib/notification-levels";
// Support for changing the notification level of various topics
export default Controller.extend({
topicBulkActions: controller(),
notificationLevelId: null,
@discourseComputed
notificationLevels() {
return topicLevels.map((level) => {
return {
id: level.id.toString(),
name: I18n.t(`topic.notifications.${level.key}.title`),
description: I18n.t(`topic.notifications.${level.key}.description`),
};
});
},
disabled: empty("notificationLevelId"),
actions: {
changeNotificationLevel() {
this.topicBulkActions.performAndRefresh({
type: "change_notification_level",
notification_level_id: this.notificationLevelId,
});
},
},
});

View File

@ -1,163 +1,231 @@
import { alias, empty } from "@ember/object/computed";
import Controller, { inject as controller } from "@ember/controller";
import { tracked } from "@glimmer/tracking";
import { inject as service } from "@ember/service";
import { action } from "@ember/object";
import I18n from "I18n";
import ModalFunctionality from "discourse/mixins/modal-functionality";
import { Promise } from "rsvp";
import Topic from "discourse/models/topic";
import ChangeCategory from "../components/bulk-actions/change-category";
import NotificationLevel from "../components/bulk-actions/notification-level";
import ChangeTags from "../components/bulk-actions/change-tags";
import AppendTags from "../components/bulk-actions/append-tags";
import { inject as service } from "@ember/service";
const _customButtons = [];
const _buttons = [];
const alwaysTrue = () => true;
function identity() {}
function addBulkButton(action, key, opts) {
opts = opts || {};
const btn = {
action,
label: `topics.bulk.${key}`,
export function _addBulkButton(opts) {
_customButtons.push({
label: opts.label,
icon: opts.icon,
buttonVisible: opts.buttonVisible || alwaysTrue,
enabledSetting: opts.enabledSetting,
class: opts.class,
};
_buttons.push(btn);
visible: opts.visible,
action: opts.action,
});
}
// Default buttons
addBulkButton("showChangeCategory", "change_category", {
icon: "pencil-alt",
class: "btn-default",
buttonVisible: (topics) => !topics.some((t) => t.isPrivateMessage),
});
addBulkButton("closeTopics", "close_topics", {
icon: "lock",
class: "btn-default",
buttonVisible: (topics) => !topics.some((t) => t.isPrivateMessage),
});
addBulkButton("archiveTopics", "archive_topics", {
icon: "folder",
class: "btn-default",
buttonVisible: (topics) => !topics.some((t) => t.isPrivateMessage),
});
addBulkButton("archiveMessages", "archive_topics", {
icon: "folder",
class: "btn-default",
buttonVisible: (topics) => topics.some((t) => t.isPrivateMessage),
});
addBulkButton("moveMessagesToInbox", "move_messages_to_inbox", {
icon: "folder",
class: "btn-default",
buttonVisible: (topics) => topics.some((t) => t.isPrivateMessage),
});
addBulkButton("showNotificationLevel", "notification_level", {
icon: "d-regular",
class: "btn-default",
});
addBulkButton("deletePostTiming", "defer", {
icon: "circle",
class: "btn-default",
buttonVisible() {
return this.currentUser.user_option.enable_defer;
},
});
addBulkButton("unlistTopics", "unlist_topics", {
icon: "far-eye-slash",
class: "btn-default",
buttonVisible: (topics) =>
topics.some((t) => t.visible) && !topics.some((t) => t.isPrivateMessage),
});
addBulkButton("relistTopics", "relist_topics", {
icon: "far-eye",
class: "btn-default",
buttonVisible: (topics) =>
topics.some((t) => !t.visible) && !topics.some((t) => t.isPrivateMessage),
});
addBulkButton("resetBumpDateTopics", "reset_bump_dates", {
icon: "anchor",
class: "btn-default",
buttonVisible() {
return this.currentUser.canManageTopic;
},
});
addBulkButton("showTagTopics", "change_tags", {
icon: "tag",
class: "btn-default",
enabledSetting: "tagging_enabled",
buttonVisible() {
return this.currentUser.canManageTopic;
},
});
addBulkButton("showAppendTagTopics", "append_tags", {
icon: "tag",
class: "btn-default",
enabledSetting: "tagging_enabled",
buttonVisible() {
return this.currentUser.canManageTopic;
},
});
addBulkButton("removeTags", "remove_tags", {
icon: "tag",
class: "btn-default",
enabledSetting: "tagging_enabled",
buttonVisible() {
return this.currentUser.canManageTopic;
},
});
addBulkButton("deleteTopics", "delete", {
icon: "trash-alt",
class: "btn-danger delete-topics",
buttonVisible() {
return this.currentUser.staff;
},
});
export function clearBulkButtons() {
_customButtons.length = 0;
}
// Modal for performing bulk actions on topics
export default Controller.extend(ModalFunctionality, {
userPrivateMessages: controller("user-private-messages"),
dialog: service(),
tags: null,
emptyTags: empty("tags"),
categoryId: alias("model.category.id"),
processedTopicCount: 0,
isGroup: alias("userPrivateMessages.isGroup"),
groupFilter: alias("userPrivateMessages.groupFilter"),
export default class TopicBulkActions extends Controller.extend(
ModalFunctionality
) {
@service currentUser;
@service siteSettings;
@service dialog;
@controller("user-private-messages") userPrivateMessages;
@tracked loading = false;
@tracked showProgress = false;
@tracked processedTopicCount = 0;
@tracked activeComponent = null;
defaultButtons = [
{
label: "topics.bulk.change_category",
icon: "pencil-alt",
class: "btn-default",
visible: ({ topics }) => !topics.some((t) => t.isPrivateMessage),
action({ setComponent }) {
setComponent(ChangeCategory);
},
},
{
label: "topics.bulk.close_topics",
icon: "lock",
class: "btn-default",
visible: ({ topics }) => !topics.some((t) => t.isPrivateMessage),
action({ forEachPerformed }) {
forEachPerformed({ type: "close" }, (t) => t.set("closed", true));
},
},
{
label: "topics.bulk.archive_topics",
icon: "folder",
class: "btn-default",
visible: ({ topics }) => !topics.some((t) => t.isPrivateMessage),
action({ forEachPerformed }) {
forEachPerformed({ type: "archive" }, (t) => t.set("archived", true));
},
},
{
label: "topics.bulk.archive_topics",
icon: "folder",
class: "btn-default",
visible: ({ topics }) => topics.some((t) => t.isPrivateMessage),
action: ({ performAndRefresh }) => {
let params = { type: "archive_messages" };
if (this.userPrivateMessages.isGroup) {
params.group = this.userPrivateMessages.groupFilter;
}
performAndRefresh(params);
},
},
{
label: "topics.bulk.move_messages_to_inbox",
icon: "folder",
class: "btn-default",
visible: ({ topics }) => topics.some((t) => t.isPrivateMessage),
action: ({ performAndRefresh }) => {
let params = { type: "move_messages_to_inbox" };
if (this.userPrivateMessages.isGroup) {
params.group = this.userPrivateMessages.groupFilter;
}
performAndRefresh(params);
},
},
{
label: "topics.bulk.notification_level",
icon: "d-regular",
class: "btn-default",
action({ setComponent }) {
setComponent(NotificationLevel);
},
},
{
label: "topics.bulk.defer",
icon: "circle",
class: "btn-default",
visible: ({ currentUser }) => currentUser.user_option.enable_defer,
action({ performAndRefresh }) {
performAndRefresh({ type: "destroy_post_timing" });
},
},
{
label: "topics.bulk.unlist_topics",
icon: "far-eye-slash",
class: "btn-default",
visible: ({ topics }) =>
topics.some((t) => t.visible) &&
!topics.some((t) => t.isPrivateMessage),
action({ forEachPerformed }) {
forEachPerformed({ type: "unlist" }, (t) => t.set("visible", false));
},
},
{
label: "topics.bulk.relist_topics",
icon: "far-eye",
class: "btn-default",
visible: ({ topics }) =>
topics.some((t) => !t.visible) &&
!topics.some((t) => t.isPrivateMessage),
action({ forEachPerformed }) {
forEachPerformed({ type: "relist" }, (t) => t.set("visible", true));
},
},
{
label: "topics.bulk.reset_bump_dates",
icon: "anchor",
class: "btn-default",
visible: ({ currentUser }) => currentUser.canManageTopic,
action({ performAndRefresh }) {
performAndRefresh({ type: "reset_bump_dates" });
},
},
{
label: "topics.bulk.change_tags",
icon: "tag",
class: "btn-default",
visible: ({ currentUser, siteSettings }) =>
siteSettings.tagging_enabled && currentUser.canManageTopic,
action({ setComponent }) {
setComponent(ChangeTags);
},
},
{
label: "topics.bulk.append_tags",
icon: "tag",
class: "btn-default",
visible: ({ currentUser, siteSettings }) =>
siteSettings.tagging_enabled && currentUser.canManageTopic,
action({ setComponent }) {
setComponent(AppendTags);
},
},
{
label: "topics.bulk.remove_tags",
icon: "tag",
class: "btn-default",
visible: ({ currentUser, siteSettings }) =>
siteSettings.tagging_enabled && currentUser.canManageTopic,
action: ({ performAndRefresh, topics }) => {
this.dialog.deleteConfirm({
message: I18n.t("topics.bulk.confirm_remove_tags", {
count: topics.length,
}),
didConfirm: () => performAndRefresh({ type: "remove_tags" }),
});
},
},
{
label: "topics.bulk.delete",
icon: "trash-alt",
class: "btn-danger delete-topics",
visible: ({ currentUser }) => currentUser.staff,
action({ performAndRefresh }) {
performAndRefresh({ type: "delete" });
},
},
];
get buttons() {
return [...this.defaultButtons, ..._customButtons].filter(({ visible }) => {
if (visible) {
return visible({
topics: this.model.topics,
category: this.model.category,
currentUser: this.currentUser,
siteSettings: this.siteSettings,
});
} else {
return true;
}
});
}
onShow() {
const topics = this.get("model.topics");
this.set(
"buttons",
_buttons.filter((b) => {
if (b.enabledSetting && !this.siteSettings[b.enabledSetting]) {
return false;
}
return b.buttonVisible.call(this, topics);
})
);
this.set("modal.modalClass", "topic-bulk-actions-modal small");
this.send("changeBulkTemplate", "modal/bulk-actions-buttons");
},
this.modal.set("modalClass", "topic-bulk-actions-modal small");
this.activeComponent = null;
}
perform(operation) {
this.set("processedTopicCount", 0);
if (this.get("model.topics").length > 20) {
this.send("changeBulkTemplate", "modal/bulk-progress");
async perform(operation) {
this.loading = true;
if (this.model.topics.length > 20) {
this.showProgress = true;
}
this.set("loading", true);
return this._processChunks(operation)
.catch(() => {
this.dialog.alert(I18n.t("generic_error"));
})
.finally(() => {
this.set("loading", false);
});
},
try {
return this._processChunks(operation);
} catch {
this.dialog.alert(I18n.t("generic_error"));
} finally {
this.loading = false;
this.processedTopicCount = 0;
this.showProgress = false;
}
}
_generateTopicChunks(allTopics) {
let startIndex = 0;
@ -165,168 +233,70 @@ export default Controller.extend(ModalFunctionality, {
const chunks = [];
while (startIndex < allTopics.length) {
let topics = allTopics.slice(startIndex, startIndex + chunkSize);
const topics = allTopics.slice(startIndex, startIndex + chunkSize);
chunks.push(topics);
startIndex += chunkSize;
}
return chunks;
},
}
_processChunks(operation) {
const allTopics = this.get("model.topics");
const allTopics = this.model.topics;
const topicChunks = this._generateTopicChunks(allTopics);
const topicIds = [];
const tasks = topicChunks.map((topics) => () => {
return Topic.bulkOperation(topics, operation).then((result) => {
this.set(
"processedTopicCount",
this.get("processedTopicCount") + topics.length
);
return result;
});
const tasks = topicChunks.map((topics) => async () => {
const result = await Topic.bulkOperation(topics, operation);
this.processedTopicCount = this.processedTopicCount + topics.length;
return result;
});
return new Promise((resolve, reject) => {
const resolveNextTask = () => {
const resolveNextTask = async () => {
if (tasks.length === 0) {
const topics = topicIds.map((id) => allTopics.findBy("id", id));
return resolve(topics);
}
tasks
.shift()()
.then((result) => {
if (result && result.topic_ids) {
topicIds.push(...result.topic_ids);
}
resolveNextTask();
})
.catch(reject);
const task = tasks.shift();
try {
const result = await task();
if (result?.topic_ids) {
topicIds.push(...result.topic_ids);
}
resolveNextTask();
} catch {
reject();
}
};
resolveNextTask();
});
},
}
forEachPerformed(operation, cb) {
this.perform(operation).then((topics) => {
if (topics) {
topics.forEach(cb);
(this.refreshClosure || identity)();
this.send("closeModal");
}
});
},
@action
setComponent(component) {
this.activeComponent = component;
}
performAndRefresh(operation) {
return this.perform(operation).then(() => {
(this.refreshClosure || identity)();
@action
async forEachPerformed(operation, cb) {
const topics = await this.perform(operation);
if (topics) {
topics.forEach(cb);
this.refreshClosure?.();
this.send("closeModal");
});
},
}
}
actions: {
showTagTopics() {
this.set("tags", "");
this.set("action", "changeTags");
this.set("label", "change_tags");
this.set("title", "choose_new_tags");
this.send("changeBulkTemplate", "bulk-tag");
},
@action
async performAndRefresh(operation) {
await this.perform(operation);
changeTags() {
this.performAndRefresh({ type: "change_tags", tags: this.tags });
},
showAppendTagTopics() {
this.set("tags", null);
this.set("action", "appendTags");
this.set("label", "append_tags");
this.set("title", "choose_append_tags");
this.send("changeBulkTemplate", "bulk-tag");
},
appendTags() {
this.performAndRefresh({ type: "append_tags", tags: this.tags });
},
showChangeCategory() {
this.send("changeBulkTemplate", "modal/bulk-change-category");
},
showNotificationLevel() {
this.send("changeBulkTemplate", "modal/bulk-notification-level");
},
deleteTopics() {
this.performAndRefresh({ type: "delete" });
},
closeTopics() {
this.forEachPerformed({ type: "close" }, (t) => t.set("closed", true));
},
archiveTopics() {
this.forEachPerformed({ type: "archive" }, (t) =>
t.set("archived", true)
);
},
archiveMessages() {
let params = { type: "archive_messages" };
if (this.isGroup) {
params.group = this.groupFilter;
}
this.performAndRefresh(params);
},
moveMessagesToInbox() {
let params = { type: "move_messages_to_inbox" };
if (this.isGroup) {
params.group = this.groupFilter;
}
this.performAndRefresh(params);
},
unlistTopics() {
this.forEachPerformed({ type: "unlist" }, (t) => t.set("visible", false));
},
relistTopics() {
this.forEachPerformed({ type: "relist" }, (t) => t.set("visible", true));
},
resetBumpDateTopics() {
this.performAndRefresh({ type: "reset_bump_dates" });
},
changeCategory() {
const categoryId = parseInt(this.newCategoryId, 10) || 0;
this.perform({ type: "change_category", category_id: categoryId }).then(
(topics) => {
topics.forEach((t) => t.set("category_id", categoryId));
(this.refreshClosure || identity)();
this.send("closeModal");
}
);
},
deletePostTiming() {
this.performAndRefresh({ type: "destroy_post_timing" });
},
removeTags() {
this.dialog.deleteConfirm({
message: I18n.t("topics.bulk.confirm_remove_tags", {
count: this.get("model.topics").length,
}),
didConfirm: () => this.performAndRefresh({ type: "remove_tags" }),
});
},
},
});
export { addBulkButton };
this.refreshClosure?.();
this.send("closeModal");
}
}

View File

@ -120,12 +120,13 @@ import { registerModelTransformer } from "discourse/lib/model-transformers";
import { registerCustomUserNavMessagesDropdownRow } from "discourse/controllers/user-private-messages";
import { registerFullPageSearchType } from "discourse/controllers/full-page-search";
import { registerHashtagType } from "discourse/lib/hashtag-autocomplete";
import { _addBulkButton } from "discourse/controllers/topic-bulk-actions";
// If you add any methods to the API ensure you bump up the version number
// based on Semantic Versioning 2.0.0. Please update the changelog at
// docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md whenever you change the version
// using the format described at https://keepachangelog.com/en/1.0.0/.
export const PLUGIN_API_VERSION = "1.7.0";
export const PLUGIN_API_VERSION = "1.7.1";
// This helper prevents us from applying the same `modifyClass` over and over in test mode.
function canModify(klass, type, resolverName, changes) {
@ -1962,15 +1963,15 @@ class PluginApi {
* })
* ```
*
* @params {Object} arg - An object
* @params {string} arg.categoryId - The id of the category
* @params {string} arg.prefixType - The type of prefix to use. Can be "icon", "image", "text" or "span".
* @params {string} arg.prefixValue - The value of the prefix to use.
* @param {Object} arg - An object
* @param {string} arg.categoryId - The id of the category
* @param {string} arg.prefixType - The type of prefix to use. Can be "icon", "image", "text" or "span".
* @param {string} arg.prefixValue - The value of the prefix to use.
* For "icon", pass in the name of a FontAwesome 5 icon.
* For "image", pass in the src of the image.
* For "text", pass in the text to display.
* For "span", pass in an array containing two hex color values. Example: `[FF0000, 000000]`.
* @params {string} arg.prefixColor - The color of the prefix to use. Example: "FF0000".
* @param {string} arg.prefixColor - The color of the prefix to use. Example: "FF0000".
*/
registerCustomCategorySectionLinkPrefix({
categoryId,
@ -2000,10 +2001,10 @@ class PluginApi {
* });
* ```
*
* @params {Object} arg - An object
* @params {string} arg.tagName - The name of the tag
* @params {string} arg.prefixValue - The name of a FontAwesome 5 icon.
* @params {string} arg.prefixColor - The color represented using hexadecimal to use for the prefix. Example: "#FF0000" or "#FFF".
* @param {Object} arg - An object
* @param {string} arg.tagName - The name of the tag
* @param {string} arg.prefixValue - The name of a FontAwesome 5 icon.
* @param {string} arg.prefixColor - The color represented using hexadecimal to use for the prefix. Example: "#FF0000" or "#FFF".
*/
registerCustomTagSectionLinkPrefixIcon({
tagName,
@ -2279,6 +2280,49 @@ class PluginApi {
registerHashtagType(type, typeClassInstance) {
registerHashtagType(type, typeClassInstance);
}
/**
* Adds a button to the bulk topic actions modal.
*
* ```
* api.addBulkActionButton({
* label: "super_plugin.bulk.enhance",
* icon: "magic",
* class: "btn-default",
* visible: ({ currentUser, siteSettings }) => siteSettings.super_plugin_enabled && currentUser.staff,
* async action({ setComponent }) {
* await doSomething(this.model.topics);
* setComponent(MyBulkModal);
* },
* });
* ```
*
* @callback buttonVisibilityCallback
* @param {Object} opts
* @param {Topic[]} opts.topics - the selected topic for the bulk action
* @param {Category} opts.category - the category in which the action is performed (if applicable)
* @param {User} opts.currentUser
* @param {SiteSettings} opts.siteSettings
* @returns {Boolean} - whether the button should be visible or not
*
* @callback buttonAction
* @param {Object} opts
* @param {Topic[]} opts.topics - the selected topic for the bulk action
* @param {Category} opts.category - the category in which the action is performed (if applicable)
* @param {function} opts.setComponent - render a template in the bulk action modal (pass in an imported component)
* @param {function} opts.performAndRefresh
* @param {function} opts.forEachPerformed
*
* @param {Object} opts
* @param {string} opts.label
* @param {string} opts.icon
* @param {string} opts.class
* @param {buttonVisibilityCallback} opts.visible
* @param {buttonAction} opts.action
*/
addBulkActionButton(opts) {
_addBulkButton(opts);
}
}
// from http://stackoverflow.com/questions/6832596/how-to-compare-software-version-number-using-js-only-number

View File

@ -1,9 +0,0 @@
<p>{{i18n (concat "topics.bulk." this.title)}}</p>
<p><TagChooser @tags={{this.tags}} @categoryId={{this.categoryId}} /></p>
<DButton
@action={{action this.action}}
@disabled={{this.emptyTags}}
@label={{concat "topics.bulk." this.label}}
/>

View File

@ -1,15 +0,0 @@
<p>{{i18n "topics.bulk.choose_new_category"}}</p>
<p>
<CategoryChooser
@value={{this.newCategoryId}}
@onChange={{action (mut this.newCategoryId)}}
/>
</p>
<ConditionalLoadingSpinner @condition={{this.loading}}>
<DButton
@action={{action "changeCategory"}}
@label="topics.bulk.change_category"
/>
</ConditionalLoadingSpinner>

View File

@ -1,3 +0,0 @@
<p>{{html-safe
(i18n "topics.bulk.progress" count=this.processedTopicCount)
}}</p>

View File

@ -1,6 +1,40 @@
<DModalBody>
<p>{{html-safe
(i18n "topics.bulk.selected" count=this.model.topics.length)
}}</p>
{{outlet "bulkOutlet"}}
<p>
{{html-safe (i18n "topics.bulk.selected" count=this.model.topics.length)}}
</p>
{{#if this.showProgress}}
<p>
{{html-safe (i18n "topics.bulk.progress" count=this.processedTopicCount)}}
</p>
{{else if this.activeComponent}}
<this.activeComponent
@loading={{this.loading}}
@topics={{this.model.topics}}
@category={{this.model.category}}
@setComponent={{this.setComponent}}
@forEachPerformed={{this.forEachPerformed}}
@performAndRefresh={{this.performAndRefresh}}
/>
{{else}}
<div class="bulk-buttons">
{{#each this.buttons as |button|}}
<DButton
@action={{fn
button.action
(hash
topics=this.model.topics
category=this.model.category
setComponent=this.setComponent
performAndRefresh=this.performAndRefresh
forEachPerformed=this.forEachPerformed
)
}}
@label={{button.label}}
@icon={{button.icon}}
class={{button.class}}
/>
{{/each}}
</div>
{{/if}}
</DModalBody>

View File

@ -1,9 +1,7 @@
import {
acceptance,
count,
exists,
invisible,
query,
queryAll,
updateCurrentUser,
} from "discourse/tests/helpers/qunit-helpers";
@ -35,85 +33,86 @@ acceptance("Topic - Bulk Actions", function (needs) {
await click(".bulk-select-actions");
assert.ok(
query("#discourse-modal-title").innerHTML.includes(
I18n.t("topics.bulk.actions")
),
"it opens bulk-select modal"
);
assert
.dom("#discourse-modal-title")
.hasText(I18n.t("topics.bulk.actions"), "it opens bulk-select modal");
assert.ok(
query(".bulk-buttons").innerHTML.includes(
I18n.t("topics.bulk.change_category")
),
"it shows an option to change category"
);
assert
.dom(".bulk-buttons")
.includesText(
I18n.t("topics.bulk.change_category"),
"it shows an option to change category"
);
assert.ok(
query(".bulk-buttons").innerHTML.includes(
I18n.t("topics.bulk.close_topics")
),
"it shows an option to close topics"
);
assert
.dom(".bulk-buttons")
.includesText(
I18n.t("topics.bulk.close_topics"),
"it shows an option to close topics"
);
assert.ok(
query(".bulk-buttons").innerHTML.includes(
I18n.t("topics.bulk.archive_topics")
),
"it shows an option to archive topics"
);
assert
.dom(".bulk-buttons")
.includesText(
I18n.t("topics.bulk.archive_topics"),
"it shows an option to archive topics"
);
assert.ok(
query(".bulk-buttons").innerHTML.includes(
I18n.t("topics.bulk.notification_level")
),
"it shows an option to update notification level"
);
assert
.dom(".bulk-buttons")
.includesText(
I18n.t("topics.bulk.notification_level"),
"it shows an option to update notification level"
);
assert.ok(
query(".bulk-buttons").innerHTML.includes(I18n.t("topics.bulk.defer")),
"it shows an option to reset read"
);
assert
.dom(".bulk-buttons")
.includesText(
I18n.t("topics.bulk.defer"),
"it shows an option to reset read"
);
assert.ok(
query(".bulk-buttons").innerHTML.includes(
I18n.t("topics.bulk.unlist_topics")
),
"it shows an option to unlist topics"
);
assert
.dom(".bulk-buttons")
.includesText(
I18n.t("topics.bulk.unlist_topics"),
"it shows an option to unlist topics"
);
assert.ok(
query(".bulk-buttons").innerHTML.includes(
I18n.t("topics.bulk.reset_bump_dates")
),
"it shows an option to reset bump dates"
);
assert
.dom(".bulk-buttons")
.includesText(
I18n.t("topics.bulk.reset_bump_dates"),
"it shows an option to reset bump dates"
);
assert.ok(
query(".bulk-buttons").innerHTML.includes(
I18n.t("topics.bulk.change_tags")
),
"it shows an option to replace tags"
);
assert
.dom(".bulk-buttons")
.includesText(
I18n.t("topics.bulk.change_tags"),
"it shows an option to replace tags"
);
assert.ok(
query(".bulk-buttons").innerHTML.includes(
I18n.t("topics.bulk.append_tags")
),
"it shows an option to append tags"
);
assert
.dom(".bulk-buttons")
.includesText(
I18n.t("topics.bulk.append_tags"),
"it shows an option to append tags"
);
assert.ok(
query(".bulk-buttons").innerHTML.includes(
I18n.t("topics.bulk.remove_tags")
),
"it shows an option to remove all tags"
);
assert
.dom(".bulk-buttons")
.includesText(
I18n.t("topics.bulk.remove_tags"),
"it shows an option to remove all tags"
);
assert.ok(
query(".bulk-buttons").innerHTML.includes(I18n.t("topics.bulk.delete")),
"it shows an option to delete topics"
);
assert
.dom(".bulk-buttons")
.includesText(
I18n.t("topics.bulk.delete"),
"it shows an option to delete topics"
);
});
test("bulk select - delete topics", async function (assert) {
@ -127,7 +126,7 @@ acceptance("Topic - Bulk Actions", function (needs) {
await click(".bulk-select-actions");
await click(".modal-body .delete-topics");
assert.ok(
assert.true(
invisible(".topic-bulk-actions-modal"),
"it closes the bulk select modal"
);
@ -164,10 +163,9 @@ acceptance("Topic - Bulk Actions", function (needs) {
test("bulk select is not available for users who are not staff or TL4", async function (assert) {
updateCurrentUser({ moderator: false, admin: false, trust_level: 1 });
await visit("/latest");
assert.notOk(
exists(".button.bulk-select"),
"non-staff and < TL4 users cannot bulk select"
);
assert
.dom(".button.bulk-select")
.doesNotExist("non-staff and < TL4 users cannot bulk select");
});
test("TL4 users can bulk select", async function (assert) {
@ -183,86 +181,87 @@ acceptance("Topic - Bulk Actions", function (needs) {
await click(queryAll("input.bulk-select")[0]);
await click(queryAll("input.bulk-select")[1]);
await click(".bulk-select-actions");
assert.ok(
query("#discourse-modal-title").innerHTML.includes(
I18n.t("topics.bulk.actions")
),
"it opens bulk-select modal"
);
assert.ok(
query(".bulk-buttons").innerHTML.includes(
I18n.t("topics.bulk.change_category")
),
"it shows an option to change category"
);
assert
.dom("#discourse-modal-title")
.hasText(I18n.t("topics.bulk.actions"), "it opens bulk-select modal");
assert.ok(
query(".bulk-buttons").innerHTML.includes(
I18n.t("topics.bulk.close_topics")
),
"it shows an option to close topics"
);
assert
.dom(".bulk-buttons")
.includesText(
I18n.t("topics.bulk.change_category"),
"it shows an option to change category"
);
assert.ok(
query(".bulk-buttons").innerHTML.includes(
I18n.t("topics.bulk.archive_topics")
),
"it shows an option to archive topics"
);
assert
.dom(".bulk-buttons")
.includesText(
I18n.t("topics.bulk.close_topics"),
"it shows an option to close topics"
);
assert.ok(
query(".bulk-buttons").innerHTML.includes(
I18n.t("topics.bulk.notification_level")
),
"it shows an option to update notification level"
);
assert
.dom(".bulk-buttons")
.includesText(
I18n.t("topics.bulk.archive_topics"),
"it shows an option to archive topics"
);
assert.notOk(
query(".bulk-buttons").innerHTML.includes(I18n.t("topics.bulk.defer")),
"it does not show an option to reset read"
);
assert
.dom(".bulk-buttons")
.includesText(
I18n.t("topics.bulk.notification_level"),
"it shows an option to update notification level"
);
assert.ok(
query(".bulk-buttons").innerHTML.includes(
I18n.t("topics.bulk.unlist_topics")
),
"it shows an option to unlist topics"
);
assert
.dom(".bulk-buttons")
.doesNotIncludeText(
I18n.t("topics.bulk.defer"),
"it does not show an option to reset read"
);
assert.ok(
query(".bulk-buttons").innerHTML.includes(
I18n.t("topics.bulk.reset_bump_dates")
),
"it shows an option to reset bump dates"
);
assert
.dom(".bulk-buttons")
.includesText(
I18n.t("topics.bulk.unlist_topics"),
"it shows an option to unlist topics"
);
assert.ok(
query(".bulk-buttons").innerHTML.includes(
I18n.t("topics.bulk.change_tags")
),
"it shows an option to replace tags"
);
assert
.dom(".bulk-buttons")
.includesText(
I18n.t("topics.bulk.reset_bump_dates"),
"it shows an option to reset bump dates"
);
assert.ok(
query(".bulk-buttons").innerHTML.includes(
I18n.t("topics.bulk.append_tags")
),
"it shows an option to append tags"
);
assert
.dom(".bulk-buttons")
.includesText(
I18n.t("topics.bulk.change_tags"),
"it shows an option to replace tags"
);
assert.ok(
query(".bulk-buttons").innerHTML.includes(
I18n.t("topics.bulk.remove_tags")
),
"it shows an option to remove all tags"
);
assert
.dom(".bulk-buttons")
.includesText(
I18n.t("topics.bulk.append_tags"),
"it shows an option to append tags"
);
assert.notOk(
query(".bulk-buttons").innerHTML.includes(I18n.t("topics.bulk.delete")),
"it does not show an option to delete topics"
);
assert
.dom(".bulk-buttons")
.includesText(
I18n.t("topics.bulk.remove_tags"),
"it shows an option to remove all tags"
);
assert
.dom(".bulk-buttons")
.doesNotIncludeText(
I18n.t("topics.bulk.delete"),
"it does not show an option to delete topics"
);
});
});

View File

@ -87,6 +87,7 @@ import { reset as resetLinkLookup } from "discourse/lib/link-lookup";
import { resetMentions } from "discourse/lib/link-mentions";
import { resetModelTransformers } from "discourse/lib/model-transformers";
import { cleanupTemporaryModuleRegistrations } from "./temporary-module-helper";
import { clearBulkButtons } from "discourse/controllers/topic-bulk-actions";
export function currentUser() {
return User.create(sessionFixtures["/session/current.json"].current_user);
@ -223,6 +224,7 @@ export function testCleanup(container, app) {
resetMentions();
cleanupTemporaryModuleRegistrations();
cleanupCssGeneratorTags();
clearBulkButtons();
}
function cleanupCssGeneratorTags() {

View File

@ -7,6 +7,12 @@ in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.7.1] - 2023-07-18
### Added
- Adds `addBulkActionButton` which adds actions to the Bulk Topic modal
## [1.7.0] - 2023-07-17
### Added