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(); + } + + +} 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", + , + { + 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