diff --git a/app/assets/javascripts/discourse/app/components/modal/bulk-topic-actions.gjs b/app/assets/javascripts/discourse/app/components/modal/bulk-topic-actions.gjs
new file mode 100644
index 00000000000..4b78e65ac8c
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/modal/bulk-topic-actions.gjs
@@ -0,0 +1,160 @@
+import Component from "@glimmer/component";
+import { action } from "@ember/object";
+import { inject as service } from "@ember/service";
+import { Promise } from "rsvp";
+import DButton from "discourse/components/d-button";
+import DModal from "discourse/components/d-modal";
+import Topic from "discourse/models/topic";
+import htmlSafe from "discourse-common/helpers/html-safe";
+import i18n from "discourse-common/helpers/i18n";
+//import AppendTags from "../bulk-actions/append-tags";
+//import ChangeCategory from "../bulk-actions/change-category";
+//import ChangeTags from "../bulk-actions/change-tags";
+//import NotificationLevel from "../bulk-actions/notification-level";
+
+export default class BulkTopicActions extends Component {
+ @service router;
+
+ async perform(operation) {
+ this.loading = true;
+
+ if (this.args.model.bulkSelectHelper.selected.length > 20) {
+ this.showProgress = true;
+ }
+
+ 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;
+ const chunkSize = 30;
+ const chunks = [];
+
+ while (startIndex < allTopics.length) {
+ chunks.push(allTopics.slice(startIndex, startIndex + chunkSize));
+ startIndex += chunkSize;
+ }
+
+ return chunks;
+ }
+
+ _processChunks(operation) {
+ const allTopics = this.args.model.bulkSelectHelper.selected;
+ const topicChunks = this._generateTopicChunks(allTopics);
+ const topicIds = [];
+ const options = {};
+
+ if (this.args.model.allowSilent === true) {
+ options.silent = true;
+ }
+
+ const tasks = topicChunks.map((topics) => async () => {
+ const result = await Topic.bulkOperation(topics, operation, options);
+ this.processedTopicCount += topics.length;
+ return result;
+ });
+
+ return new Promise((resolve, reject) => {
+ const resolveNextTask = async () => {
+ if (tasks.length === 0) {
+ const topics = topicIds.map((id) => allTopics.findBy("id", id));
+ return resolve(topics);
+ }
+
+ const task = tasks.shift();
+
+ try {
+ const result = await task();
+ if (result?.topic_ids) {
+ topicIds.push(...result.topic_ids);
+ }
+ resolveNextTask();
+ } catch {
+ reject();
+ }
+ };
+
+ resolveNextTask();
+ });
+ }
+
+ @action
+ setComponent(component) {
+ this.activeComponent = component;
+ }
+
+ @action
+ performAction() {
+ switch (this.args.model.action) {
+ case "close":
+ this.forEachPerformed({ type: "close" }, (t) => t.set("closed", true));
+ break;
+ }
+ }
+
+ @action
+ async forEachPerformed(operation, cb) {
+ const topics = await this.perform(operation);
+
+ if (topics) {
+ topics.forEach(cb);
+ this.args.model.refreshClosure?.();
+ this.args.closeModal();
+ this.args.model.bulkSelectHelper.toggleBulkSelect();
+ }
+ }
+
+ @action
+ async performAndRefresh(operation) {
+ await this.perform(operation);
+
+ this.args.model.refreshClosure?.();
+ this.args.closeModal();
+ }
+
+
+
+ <:body>
+
+ {{htmlSafe (i18n "topics.bulk.selected" count=@model.topics.length)}}
+
+
+
+ <:footer>
+ {{#if @model.allowSilent}}
+
+
+
+ {{/if}}
+
+
+
+
+
+}
diff --git a/app/assets/javascripts/discourse/app/components/modal/topic-bulk-actions.js b/app/assets/javascripts/discourse/app/components/modal/topic-bulk-actions.js
index b0ec5bfc6ed..86bd9536942 100644
--- a/app/assets/javascripts/discourse/app/components/modal/topic-bulk-actions.js
+++ b/app/assets/javascripts/discourse/app/components/modal/topic-bulk-actions.js
@@ -196,6 +196,14 @@ export default class TopicBulkActions extends Component {
},
];
+ constructor() {
+ super(...arguments);
+
+ if (this.args.model.initialAction === "set-component") {
+ this.setComponent(this.args.model.initialComponent);
+ }
+ }
+
get buttons() {
return [...this.defaultButtons, ..._customButtons].filter(({ visible }) => {
if (visible) {
diff --git a/app/assets/javascripts/discourse/app/components/topic-list.hbs b/app/assets/javascripts/discourse/app/components/topic-list.hbs
index 1bdf6b76e8e..26613c77bdc 100644
--- a/app/assets/javascripts/discourse/app/components/topic-list.hbs
+++ b/app/assets/javascripts/discourse/app/components/topic-list.hbs
@@ -12,6 +12,8 @@
sortable=this.sortable
listTitle=this.listTitle
bulkSelectEnabled=this.bulkSelectEnabled
+ bulkSelectHelper=this.bulkSelectHelper
+ experimentalTopicBulkActionsEnabled=this.experimentalTopicBulkActionsEnabled
canDoBulkActions=this.canDoBulkActions
showTopicsAndRepliesToggle=this.showTopicsAndRepliesToggle
newListSubset=this.newListSubset
diff --git a/app/assets/javascripts/discourse/app/components/topic-list.js b/app/assets/javascripts/discourse/app/components/topic-list.js
index c711b0da984..9e4a2b4d4d5 100644
--- a/app/assets/javascripts/discourse/app/components/topic-list.js
+++ b/app/assets/javascripts/discourse/app/components/topic-list.js
@@ -48,6 +48,11 @@ export default Component.extend(LoadMore, {
);
},
+ @discourseComputed
+ experimentalTopicBulkActionsEnabled() {
+ return this.currentUser?.use_experimental_topic_bulk_actions;
+ },
+
@discourseComputed
sortable() {
return !!this.changeSort;
diff --git a/app/assets/javascripts/discourse/app/lib/bulk-select-helper.js b/app/assets/javascripts/discourse/app/lib/bulk-select-helper.js
index 10205b67caf..ad152c0106a 100644
--- a/app/assets/javascripts/discourse/app/lib/bulk-select-helper.js
+++ b/app/assets/javascripts/discourse/app/lib/bulk-select-helper.js
@@ -49,7 +49,7 @@ export default class BulkSelectHelper {
this.router.currentRoute.queryParams["filter"]) === "tracked";
const promise = this.selected.length
- ? Topic.bulkOperation(this.selected, operation, isTracked)
+ ? Topic.bulkOperation(this.selected, operation, {}, isTracked)
: Topic.bulkOperationByFilter("unread", operation, options, isTracked);
promise.then((result) => {
diff --git a/app/assets/javascripts/discourse/app/models/topic.js b/app/assets/javascripts/discourse/app/models/topic.js
index 1794742628c..ecdeae99969 100644
--- a/app/assets/javascripts/discourse/app/models/topic.js
+++ b/app/assets/javascripts/discourse/app/models/topic.js
@@ -806,13 +806,19 @@ Topic.reopenClass({
return promise;
},
- bulkOperation(topics, operation, tracked) {
+ bulkOperation(topics, operation, options, tracked) {
const data = {
topic_ids: topics.mapBy("id"),
operation,
tracked,
};
+ if (options) {
+ if (options.select) {
+ data.silent = true;
+ }
+ }
+
return ajax("/topics/bulk", {
type: "PUT",
data,
diff --git a/app/assets/javascripts/discourse/app/raw-templates/topic-bulk-select-dropdown.hbr b/app/assets/javascripts/discourse/app/raw-templates/topic-bulk-select-dropdown.hbr
new file mode 100644
index 00000000000..82b20e114f7
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/raw-templates/topic-bulk-select-dropdown.hbr
@@ -0,0 +1 @@
+{{{view.html}}}
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/app/raw-templates/topic-list-header-column.hbr b/app/assets/javascripts/discourse/app/raw-templates/topic-list-header-column.hbr
index 0a6fc5c4747..475c8d1b90c 100644
--- a/app/assets/javascripts/discourse/app/raw-templates/topic-list-header-column.hbr
+++ b/app/assets/javascripts/discourse/app/raw-templates/topic-list-header-column.hbr
@@ -1,12 +1,22 @@
{{~#if canBulkSelect}}
{{~#if showBulkToggle}}
- {{raw "flat-button" class="bulk-select" icon="list" title="topics.bulk.toggle"}}
+ {{~#if experimentalTopicBulkActionsEnabled }}
+ {{raw "flat-button" class="bulk-select" icon="tasks" title="topics.bulk.toggle"}}
+ {{else}}
+ {{raw "flat-button" class="bulk-select" icon="list" title="topics.bulk.toggle"}}
+ {{/if ~}}
{{/if ~}}
{{~#if bulkSelectEnabled}}
{{~#if canDoBulkActions}}
-
+ {{~#if experimentalTopicBulkActionsEnabled }}
+ {{raw "topic-bulk-select-dropdown" bulkSelectHelper=bulkSelectHelper}}
+ {{! Just showing both buttons for now for development}}
+
+ {{else}}
+
+ {{/if ~}}
{{/if ~}}
diff --git a/app/assets/javascripts/discourse/app/raw-templates/topic-list-header.hbr b/app/assets/javascripts/discourse/app/raw-templates/topic-list-header.hbr
index fba5a9dfec1..cd65576495a 100644
--- a/app/assets/javascripts/discourse/app/raw-templates/topic-list-header.hbr
+++ b/app/assets/javascripts/discourse/app/raw-templates/topic-list-header.hbr
@@ -2,11 +2,15 @@
{{#if bulkSelectEnabled}}
|
{{#if canBulkSelect}}
- {{raw "flat-button" class="bulk-select" icon="list" title="topics.bulk.toggle"}}
+ {{#if experimentalTopicBulkActionsEnabled }}
+ {{raw "flat-button" class="bulk-select" icon="tasks" title="topics.bulk.toggle"}}
+ {{else}}
+ {{raw "flat-button" class="bulk-select" icon="list" title="topics.bulk.toggle"}}
+ {{/if}}
{{/if}}
|
{{/if}}
-{{raw "topic-list-header-column" order='default' name=listTitle bulkSelectEnabled=bulkSelectEnabled showBulkToggle=toggleInTitle canBulkSelect=canBulkSelect canDoBulkActions=canDoBulkActions showTopicsAndRepliesToggle=showTopicsAndRepliesToggle newListSubset=newListSubset newRepliesCount=newRepliesCount newTopicsCount=newTopicsCount}}
+{{raw "topic-list-header-column" order='default' name=listTitle bulkSelectEnabled=bulkSelectEnabled showBulkToggle=toggleInTitle canBulkSelect=canBulkSelect canDoBulkActions=canDoBulkActions showTopicsAndRepliesToggle=showTopicsAndRepliesToggle newListSubset=newListSubset newRepliesCount=newRepliesCount newTopicsCount=newTopicsCount experimentalTopicBulkActionsEnabled=experimentalTopicBulkActionsEnabled bulkSelectHelper=bulkSelectHelper }}
{{raw-plugin-outlet name="topic-list-header-after-main-link"}}
{{#if showPosters}}
{{raw "topic-list-header-column" order='posters' ariaLabel=(i18n "category.sort_options.posters")}}
diff --git a/app/assets/javascripts/discourse/app/raw-views/topic-bulk-select-dropdown.gjs b/app/assets/javascripts/discourse/app/raw-views/topic-bulk-select-dropdown.gjs
new file mode 100644
index 00000000000..a7e52b0f400
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/raw-views/topic-bulk-select-dropdown.gjs
@@ -0,0 +1,26 @@
+import EmberObject from "@ember/object";
+import rawRenderGlimmer from "discourse/lib/raw-render-glimmer";
+import BulkSelectTopicsDropdown from "select-kit/components/bulk-select-topics-dropdown";
+
+export default class extends EmberObject {
+ get selectedCount() {
+ return this.bulkSelectHelper.selected.length;
+ }
+
+ get html() {
+ return rawRenderGlimmer(
+ this,
+ "div.bulk-select-topics-dropdown",
+
+ {{@data.selectedCount}} selected
+
+ ,
+ {
+ bulkSelectHelper: this.bulkSelectHelper,
+ selectedCount: this.selectedCount,
+ }
+ );
+ }
+}
diff --git a/app/assets/javascripts/select-kit/addon/components/bulk-select-topics-dropdown.js b/app/assets/javascripts/select-kit/addon/components/bulk-select-topics-dropdown.js
new file mode 100644
index 00000000000..5a7182c9126
--- /dev/null
+++ b/app/assets/javascripts/select-kit/addon/components/bulk-select-topics-dropdown.js
@@ -0,0 +1,68 @@
+import { action } from "@ember/object";
+import { inject as service } from "@ember/service";
+import ChangeCategory from "discourse/components/bulk-actions/change-category";
+import BulkTopicActions from "discourse/components/modal/bulk-topic-actions";
+import TopicBulkActions from "discourse/components/modal/topic-bulk-actions";
+import i18n from "discourse-common/helpers/i18n";
+import DropdownSelectBoxComponent from "select-kit/components/dropdown-select-box";
+
+export default DropdownSelectBoxComponent.extend({
+ classNames: ["bulk-select-topics-dropdown"],
+ headerIcon: null,
+ showFullTitle: true,
+ selectKitOptions: {
+ showCaret: true,
+ showFullTitle: true,
+ none: "select_kit.components.bulk_select_topics_dropdown.title",
+ },
+
+ modal: service(),
+ router: service(),
+
+ computeContent() {
+ let options = [];
+ options = options.concat([
+ {
+ id: "update-category",
+ icon: "pencil-alt",
+ name: i18n("topic_bulk_actions.update_category.name"),
+ description: i18n("topic_bulk_actions.update_category.description"),
+ },
+ {
+ id: "close-topics",
+ icon: "lock",
+ name: i18n("topic_bulk_actions.close_topics.name"),
+ },
+ ]);
+ return options;
+ },
+
+ @action
+ onSelect(id) {
+ switch (id) {
+ case "update-category":
+ // Temporary: just use the existing modal & action
+ this.modal.show(TopicBulkActions, {
+ model: {
+ topics: this.bulkSelectHelper.selected,
+ category: this.category,
+ refreshClosure: () => this.router.refresh(),
+ initialAction: "set-component",
+ initialComponent: ChangeCategory,
+ },
+ });
+ break;
+ case "close-topics":
+ this.modal.show(BulkTopicActions, {
+ model: {
+ action: "close",
+ title: i18n("topics.bulk.close_topics"),
+ bulkSelectHelper: this.bulkSelectHelper,
+ refreshClosure: () => this.router.refresh(),
+ allowSilent: true,
+ },
+ });
+ break;
+ }
+ },
+});
diff --git a/app/assets/stylesheets/common/modal/modal-overrides.scss b/app/assets/stylesheets/common/modal/modal-overrides.scss
index 38e17f40870..c9908e75c5c 100644
--- a/app/assets/stylesheets/common/modal/modal-overrides.scss
+++ b/app/assets/stylesheets/common/modal/modal-overrides.scss
@@ -218,6 +218,20 @@
}
}
+.d-modal.topic-bulk-actions-modal {
+ .d-modal {
+ &__container {
+ display: flex;
+ }
+ }
+ p {
+ margin-top: 0;
+ }
+ #bulk-topics-confirm {
+ margin-left: auto;
+ }
+}
+
.d-modal.edit-slow-mode-modal {
.slow-mode-label {
display: inline-flex;
diff --git a/app/assets/stylesheets/desktop/topic-list.scss b/app/assets/stylesheets/desktop/topic-list.scss
index 12b9bf22ff6..f14d5197ce4 100644
--- a/app/assets/stylesheets/desktop/topic-list.scss
+++ b/app/assets/stylesheets/desktop/topic-list.scss
@@ -200,6 +200,22 @@
}
}
+.arrow {
+ border: solid white;
+ border-width: 0 3px 3px 0;
+ display: inline-block;
+ padding: 3px;
+ margin-left: 4px;
+}
+
+.down {
+ transform: rotate(45deg);
+ -webkit-transform: rotate(45deg);
+}
+
+.bulk-select-actions {
+}
+
.dismiss-container-top {
display: flex;
justify-content: flex-end;
diff --git a/app/serializers/current_user_serializer.rb b/app/serializers/current_user_serializer.rb
index 7dea57562a7..faa14d55ff9 100644
--- a/app/serializers/current_user_serializer.rb
+++ b/app/serializers/current_user_serializer.rb
@@ -66,7 +66,8 @@ class CurrentUserSerializer < BasicUserSerializer
:sidebar_tags,
:sidebar_category_ids,
:sidebar_sections,
- :new_new_view_enabled?
+ :new_new_view_enabled?,
+ :use_experimental_topic_bulk_actions?
delegate :user_stat, to: :object, private: true
delegate :any_posts, :draft_count, :pending_posts_count, :read_faq?, to: :user_stat
@@ -277,4 +278,8 @@ class CurrentUserSerializer < BasicUserSerializer
def unseen_reviewable_count
Reviewable.unseen_reviewable_count(object)
end
+
+ def use_experimental_topic_bulk_actions?
+ scope.user.in_any_groups?(SiteSetting.experimental_topic_bulk_actions_enabled_groups_map)
+ end
end
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index d456e346d39..08e4edde2e4 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -2349,6 +2349,8 @@ en:
filter_for_more: Filter for moreā¦
categories_admin_dropdown:
title: "Manage categories"
+ bulk_select_topics_dropdown:
+ title: "Bulk Actions"
date_time_picker:
from: From
@@ -2886,6 +2888,7 @@ en:
topics:
new_messages_marker: "last visit"
bulk:
+ confirm: "Confirm"
select_all: "Select All"
clear_all: "Clear All"
unlist_topics: "Unlist Topics"
@@ -2944,6 +2947,7 @@ en:
progress:
one: "Progress: %{count} topic"
other: "Progress: %{count} topics"
+ silent: "Perform this action silently."
none:
unread: "You have no unread topics."
@@ -2975,6 +2979,13 @@ en:
bookmarks: "There are no more bookmarked topics."
filter: "There are no more topics."
+ topic_bulk_actions:
+ close_topics:
+ name: "Close Topics"
+ update_category:
+ name: "Update Category"
+ description: "Choose the new category for the selected topics"
+
topic:
filter_to:
one: "%{count} post in topic"
diff --git a/config/site_settings.yml b/config/site_settings.yml
index 1e31ec5dfff..d1fa01ca077 100644
--- a/config/site_settings.yml
+++ b/config/site_settings.yml
@@ -2355,6 +2355,13 @@ developer:
default: ""
client: true
hidden: true
+ experimental_topic_bulk_actions_enabled_groups:
+ default: ""
+ hidden: true
+ type: group_list
+ list_type: compact
+ allow_any: false
+ refresh: true
navigation:
navigation_menu:
diff --git a/spec/system/page_objects/components/topic_list.rb b/spec/system/page_objects/components/topic_list.rb
index 49b97ead3b0..eda22e2279d 100644
--- a/spec/system/page_objects/components/topic_list.rb
+++ b/spec/system/page_objects/components/topic_list.rb
@@ -30,6 +30,18 @@ module PageObjects
page.has_no_css?(topic_list_item_class(topic))
end
+ def has_topic_checkbox?(topic)
+ page.has_css?("#{topic_list_item_class(topic)} input#bulk-select-#{topic.id}")
+ end
+
+ def has_closed_status?(topic)
+ page.has_css?("#{topic_list_item_closed(topic)}")
+ end
+
+ def click_topic_checkbox(topic)
+ find("#{topic_list_item_class(topic)} input#bulk-select-#{topic.id}").click
+ end
+
def visit_topic_with_title(title)
find("#{TOPIC_LIST_BODY_SELECTOR} a", text: title).click
end
@@ -52,6 +64,10 @@ module PageObjects
def topic_list_item_class(topic)
"#{TOPIC_LIST_ITEM_SELECTOR}[data-topic-id='#{topic.id}']"
end
+
+ def topic_list_item_closed(topic)
+ "#{topic_list_item_class(topic)} .topic-statuses .topic-status svg.locked"
+ end
end
end
end
diff --git a/spec/system/page_objects/components/topic_list_header.rb b/spec/system/page_objects/components/topic_list_header.rb
new file mode 100644
index 00000000000..259dc51fe20
--- /dev/null
+++ b/spec/system/page_objects/components/topic_list_header.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+module PageObjects
+ module Components
+ class TopicListHeader < PageObjects::Components::Base
+ TOPIC_LIST_HEADER_SELECTOR = ".topic-list .topic-list-header"
+ TOPIC_LIST_DATA_SELECTOR = "#{TOPIC_LIST_HEADER_SELECTOR} .topic-list-data"
+
+ def topic_list_header
+ TOPIC_LIST_HEADER_SELECTOR
+ end
+
+ def has_bulk_select_button?
+ page.has_css?("#{TOPIC_LIST_HEADER_SELECTOR} button.bulk-select")
+ end
+
+ def click_bulk_select_button
+ find("#{TOPIC_LIST_HEADER_SELECTOR} button.bulk-select").click
+ end
+
+ def has_bulk_select_topics_dropdown?
+ page.has_css?(
+ "#{TOPIC_LIST_HEADER_SELECTOR} .bulk-select-topics div.bulk-select-topics-dropdown",
+ )
+ end
+
+ def click_bulk_select_topics_dropdown
+ find(
+ "#{TOPIC_LIST_HEADER_SELECTOR} .bulk-select-topics div.bulk-select-topics-dropdown",
+ ).click
+ end
+
+ def has_close_topics_button?
+ page.has_css?(bulk_select_dropdown_item("close-topics"))
+ end
+
+ def click_close_topics_button
+ find(bulk_select_dropdown_item("close-topics")).click
+ end
+
+ def has_bulk_select_modal?
+ page.has_css?("#discourse-modal-title")
+ end
+
+ def click_bulk_topics_confirm
+ find("#bulk-topics-confirm").click
+ end
+
+ private
+
+ def bulk_select_dropdown_item(name)
+ "#{TOPIC_LIST_HEADER_SELECTOR} .bulk-select-topics div.bulk-select-topics-dropdown li[data-value='#{name}']"
+ end
+ end
+ end
+end
diff --git a/spec/system/topic_bulk_select_spec.rb b/spec/system/topic_bulk_select_spec.rb
new file mode 100644
index 00000000000..fe8ae95f56a
--- /dev/null
+++ b/spec/system/topic_bulk_select_spec.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+describe "Topic bulk select", type: :system do
+ before { SiteSetting.experimental_topic_bulk_actions_enabled_groups = "1" }
+ fab!(:topics) { Fabricate.times(10, :post).map(&:topic) }
+ let(:topic_list_header) { PageObjects::Components::TopicListHeader.new }
+ let(:topic_list) { PageObjects::Components::TopicList.new }
+
+ context "when in topic" do
+ fab!(:admin)
+
+ before { sign_in(admin) }
+
+ it "closes multiple topics" do
+ visit("/latest")
+ expect(page).to have_css(".topic-list button.bulk-select")
+ expect(topic_list_header).to have_bulk_select_button
+
+ # Click bulk select button
+ topic_list_header.click_bulk_select_button
+ expect(topic_list).to have_topic_checkbox(topics.first)
+
+ # Select Topics
+ topic_list.click_topic_checkbox(topics.first)
+ topic_list.click_topic_checkbox(topics.second)
+
+ # Has Dropdown
+ expect(topic_list_header).to have_bulk_select_topics_dropdown
+ topic_list_header.click_bulk_select_topics_dropdown
+
+ # Clicking the close button opens up the modal
+ expect(topic_list_header).to have_close_topics_button
+ topic_list_header.click_close_topics_button
+ expect(topic_list_header).to have_bulk_select_modal
+
+ # Closes the selected topics
+ topic_list_header.click_bulk_topics_confirm
+ expect(topic_list).to have_closed_status(topics.first)
+ end
+ end
+end