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 index 0e4d2987408..3aedaf99115 100644 --- a/app/assets/javascripts/discourse/app/components/modal/bulk-topic-actions.gjs +++ b/app/assets/javascripts/discourse/app/components/modal/bulk-topic-actions.gjs @@ -2,14 +2,16 @@ import Component from "@glimmer/component"; import { tracked } from "@glimmer/tracking"; import { Input } from "@ember/component"; import { on } from "@ember/modifier"; -import { action, computed } from "@ember/object"; +import { action } from "@ember/object"; import { service } from "@ember/service"; import { Promise } from "rsvp"; import ConditionalLoadingSection from "discourse/components/conditional-loading-section"; import DButton from "discourse/components/d-button"; import DModal from "discourse/components/d-modal"; import RadioButton from "discourse/components/radio-button"; +import { categoryBadgeHTML } from "discourse/helpers/category-link"; import { topicLevels } from "discourse/lib/notification-levels"; +import Category from "discourse/models/category"; import Topic from "discourse/models/topic"; import autoFocus from "discourse/modifiers/auto-focus"; import htmlSafe from "discourse-common/helpers/html-safe"; @@ -39,9 +41,9 @@ export default class BulkTopicActions extends Component { constructor() { super(...arguments); - if (this.args.model.initialAction === "set-component") { - if (this.args.model.initialActionLabel in _customActions) { - _customActions[this.args.model.initialActionLabel]({ + if (this.model.initialAction === "set-component") { + if (this.model.initialActionLabel in _customActions) { + _customActions[this.model.initialActionLabel]({ setComponent: this.setComponent.bind(this), }); } @@ -49,7 +51,7 @@ export default class BulkTopicActions extends Component { } async perform(operation) { - if (this.args.model.bulkSelectHelper.selected.length > 20) { + if (this.model.bulkSelectHelper.selected.length > 20) { this.showProgress = true; } @@ -78,7 +80,7 @@ export default class BulkTopicActions extends Component { } _processChunks(operation) { - const allTopics = this.args.model.bulkSelectHelper.selected; + const allTopics = this.model.bulkSelectHelper.selected; const topicChunks = this._generateTopicChunks(allTopics); const topicIds = []; const options = {}; @@ -134,7 +136,7 @@ export default class BulkTopicActions extends Component { @action performAction() { this.loading = true; - switch (this.args.model.action) { + switch (this.model.action) { case "close": this.forEachPerformed({ type: "close" }, (t) => t.set("closed", true)); break; @@ -192,7 +194,7 @@ export default class BulkTopicActions extends Component { if (this.customAction) { this.customAction(this.performAndRefresh.bind(this)); } else { - _customActions[this.args.model.initialActionLabel](this); + _customActions[this.model.initialActionLabel](this); } } } @@ -218,9 +220,9 @@ export default class BulkTopicActions extends Component { if (topics) { topics.forEach(cb); - this.args.model.refreshClosure?.(); + this.model.refreshClosure?.(); this.args.closeModal(); - this.args.model.bulkSelectHelper.toggleBulkSelect(); + this.model.bulkSelectHelper.toggleBulkSelect(); this.showToast(); } } @@ -229,33 +231,30 @@ export default class BulkTopicActions extends Component { async performAndRefresh(operation) { await this.perform(operation); - this.args.model.refreshClosure?.(); - this.args.closeModal(); - this.args.model.bulkSelectHelper.toggleBulkSelect(); - this.showToast(); + this.model.refreshClosure?.().then(() => { + this.args.closeModal(); + this.model.bulkSelectHelper.toggleBulkSelect(); + this.showToast(); + }); } - @computed("action") get isTagAction() { return ( - this.args.model.action === "append-tags" || - this.args.model.action === "replace-tags" + this.model.action === "append-tags" || + this.model.action === "replace-tags" ); } - @computed("action") get isNotificationAction() { - return this.args.model.action === "update-notifications"; + return this.model.action === "update-notifications"; } - @computed("action") get isCategoryAction() { - return this.args.model.action === "update-category"; + return this.model.action === "update-category"; } - @computed("action") get isCloseAction() { - return this.args.model.action === "close"; + return this.model.action === "close"; } @action @@ -264,6 +263,10 @@ export default class BulkTopicActions extends Component { this.closeNote = event.target.value; } + get model() { + return this.args.model; + } + get notificationLevels() { return topicLevels.map((level) => ({ id: level.id.toString(), @@ -272,6 +275,32 @@ export default class BulkTopicActions extends Component { })); } + get soleCategoryId() { + if (this.model.bulkSelectHelper.selectedCategoryIds.length === 1) { + return this.model.bulkSelectHelper.selectedCategoryIds[0]; + } + + return null; + } + + get soleCategory() { + if (!this.soleCategoryId) { + return null; + } + + return Category.findById(this.soleCategoryId); + } + + get soleCategoryBadgeHTML() { + return categoryBadgeHTML(this.soleCategory, { + allowUncategorized: true, + }); + } + + get showSoleCategoryTip() { + return this.soleCategory && this.isTagAction; + } + @action onCategoryChange(categoryId) { this.categoryId = categoryId; @@ -289,13 +318,25 @@ export default class BulkTopicActions extends Component { @isLoading={{this.loading}} @title={{i18n "topics.bulk.performing"}} > -
- {{htmlSafe - (i18n - "topics.bulk.selected" - count=@model.bulkSelectHelper.selected.length - ) - }} +
+ + {{#if this.showSoleCategoryTip}} + {{htmlSafe + (i18n + "topics.bulk.selected_sole_category" + count=@model.bulkSelectHelper.selected.length + ) + }} + {{htmlSafe this.soleCategoryBadgeHTML}} + {{else}} + {{htmlSafe + (i18n + "topics.bulk.selected" + count=@model.bulkSelectHelper.selected.length + ) + }} + + {{/if}}
{{#if this.isCategoryAction}} @@ -330,7 +371,7 @@ export default class BulkTopicActions extends Component { {{#if this.isTagAction}}

{{/if}} 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 75558fdec5b..743ad117dc7 100644 --- a/app/assets/javascripts/discourse/app/lib/bulk-select-helper.js +++ b/app/assets/javascripts/discourse/app/lib/bulk-select-helper.js @@ -29,6 +29,10 @@ export default class BulkSelectHelper { this.selected.concat(topics); } + get selectedCategoryIds() { + return this.selected.mapBy("category_id").uniq(); + } + @bind toggleBulkSelect() { this.bulkSelectEnabled = !this.bulkSelectEnabled; diff --git a/app/assets/stylesheets/common/modal/modal-overrides.scss b/app/assets/stylesheets/common/modal/modal-overrides.scss index db219f56d7e..9865f5bf79e 100644 --- a/app/assets/stylesheets/common/modal/modal-overrides.scss +++ b/app/assets/stylesheets/common/modal/modal-overrides.scss @@ -218,7 +218,17 @@ } } +.topic-bulk-actions-modal { + &__selection-info { + margin-bottom: 0.5em; + } +} + .d-modal.topic-bulk-actions-modal { + .d-modal__title-text { + font-size: var(--font-up-2); + } + .d-modal { &__container { display: flex; diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 58385fe6eab..0bbb98fea3a 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -3000,6 +3000,9 @@ en: selected: one: "You have selected %{count} topic." other: "You have selected %{count} topics." + selected_sole_category: + one: "You have selected %{count} topic from category:" + other: "You have selected %{count} topics from category:" selected_count: one: "%{count} selected" other: "%{count} selected" diff --git a/spec/system/page_objects/components/category_badge.rb b/spec/system/page_objects/components/category_badge.rb new file mode 100644 index 00000000000..929ad39f9ec --- /dev/null +++ b/spec/system/page_objects/components/category_badge.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module PageObjects + module Components + class CategoryBadge < PageObjects::Components::Base + SELECTOR = ".badge-category__wrapper" + + def find_for_category(category) + find(category_selector(category)) + end + + def category_selector(category) + "#{SELECTOR} .badge-category[data-category-id='#{category.id}']" + end + end + end +end diff --git a/spec/system/page_objects/components/topic_list.rb b/spec/system/page_objects/components/topic_list.rb index 9be201d9b7f..7b5e4626c09 100644 --- a/spec/system/page_objects/components/topic_list.rb +++ b/spec/system/page_objects/components/topic_list.rb @@ -75,12 +75,12 @@ module PageObjects find("#topic-entrance button.jump-top").native.send_keys(:return) end - private - def topic_list_item_class(topic) "#{TOPIC_LIST_ITEM_SELECTOR}[data-topic-id='#{topic.id}']" end + private + def topic_list_item_closed(topic) "#{topic_list_item_class(topic)} .topic-statuses .topic-status svg.locked" end diff --git a/spec/system/page_objects/components/topic_list_header.rb b/spec/system/page_objects/components/topic_list_header.rb index 79a96430984..2d689a26a8b 100644 --- a/spec/system/page_objects/components/topic_list_header.rb +++ b/spec/system/page_objects/components/topic_list_header.rb @@ -30,6 +30,12 @@ module PageObjects ).click end + def click_bulk_button(name) + find(bulk_select_dropdown_item(name)).click + end + + # TODO (martin) Remove all this once discourse-assign is using the new bulk select + # modal page object in specs. def has_close_topics_button? page.has_css?(bulk_select_dropdown_item("close-topics")) end @@ -57,6 +63,7 @@ module PageObjects def click_dismiss_read_confirm find("#dismiss-read-confirm").click end + ### /TODO private diff --git a/spec/system/page_objects/modals/topic_bulk_actions.rb b/spec/system/page_objects/modals/topic_bulk_actions.rb new file mode 100644 index 00000000000..f0b541d81c0 --- /dev/null +++ b/spec/system/page_objects/modals/topic_bulk_actions.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true +module PageObjects + module Modals + class TopicBulkActions < PageObjects::Modals::Base + MODAL_SELECTOR = ".topic-bulk-actions-modal" + + def tag_selector + PageObjects::Components::SelectKit.new(".tag-chooser") + end + + def click_bulk_topics_confirm + find("#bulk-topics-confirm").click + end + + def click_silent + find("#topic-bulk-action-options__silent").click + end + + def fill_in_close_note(message) + find("#bulk-close-note").set(message) + end + + def has_category_badge?(category) + within(MODAL_SELECTOR) do + PageObjects::Components::CategoryBadge.new.find_for_category(category) + end + end + + def has_no_category_badge?(category) + within(MODAL_SELECTOR) do + has_no_css?(PageObjects::Components::CategoryBadge.new.category_selector(category)) + end + end + end + end +end diff --git a/spec/system/topic_bulk_select_spec.rb b/spec/system/topic_bulk_select_spec.rb index 17d365c8a92..51cf3dca3b2 100644 --- a/spec/system/topic_bulk_select_spec.rb +++ b/spec/system/topic_bulk_select_spec.rb @@ -3,19 +3,115 @@ describe "Topic bulk select", type: :system do before { SiteSetting.experimental_topic_bulk_actions_enabled_groups = "1" } fab!(:topics) { Fabricate.times(10, :post).map(&:topic) } + fab!(:admin) + fab!(:user) + let(:topic_list_header) { PageObjects::Components::TopicListHeader.new } let(:topic_list) { PageObjects::Components::TopicList.new } let(:topic_page) { PageObjects::Pages::Topic.new } + let(:topic_bulk_actions_modal) { PageObjects::Modals::TopicBulkActions.new } - context "when in topic" do - fab!(:admin) - fab!(:user) + context "when appending tags" do + fab!(:tag1) { Fabricate(:tag) } + fab!(:tag2) { Fabricate(:tag) } + fab!(:tag3) { Fabricate(:tag) } + before { SiteSetting.tagging_enabled = true } + + def open_append_modal(topics_to_select = nil) + sign_in(admin) + visit("/latest") + + topic_list_header.click_bulk_select_button + + if !topics_to_select + topic_list.click_topic_checkbox(topics.last) + else + topics_to_select.each { |topic| topic_list.click_topic_checkbox(topic) } + end + + topic_list_header.click_bulk_select_topics_dropdown + topic_list_header.click_bulk_button("append-tags") + expect(topic_bulk_actions_modal).to be_open + end + + it "appends tags to selected topics" do + open_append_modal + + topic_bulk_actions_modal.tag_selector.expand + topic_bulk_actions_modal.tag_selector.search(tag1.name) + topic_bulk_actions_modal.tag_selector.select_row_by_value(tag1.name) + topic_bulk_actions_modal.tag_selector.search(tag2.name) + topic_bulk_actions_modal.tag_selector.select_row_by_value(tag2.name) + + topic_bulk_actions_modal.click_bulk_topics_confirm + + expect( + find(topic_list.topic_list_item_class(topics.last)).find(".discourse-tags"), + ).to have_content(tag1.name) + expect( + find(topic_list.topic_list_item_class(topics.last)).find(".discourse-tags"), + ).to have_content(tag2.name) + end + + context "when selecting topics in different categories" do + before do + topics + .last(2) + .each do |topic| + topic.update!(category: Fabricate(:category)) + topic.update!(category: Fabricate(:category)) + end + end + + it "does not show an additional note about the category in the modal" do + open_append_modal(topics.last(2)) + + expect(topic_bulk_actions_modal).to have_no_category_badge(topics.last.reload.category) + end + end + + context "when selecting topics that are all in the same category" do + fab!(:category) + + before { topics.last.update!(category_id: category.id) } + + it "shows an additional note about the category in the modal" do + open_append_modal + expect(topic_bulk_actions_modal).to have_category_badge(category) + end + + it "allows for searching restricted tags for that category and other tags too if the category allows it" do + restricted_tag_group = Fabricate(:tag_group) + restricted_tag = Fabricate(:tag) + TagGroupMembership.create!(tag: restricted_tag, tag_group: restricted_tag_group) + CategoryTagGroup.create!(category: category, tag_group: restricted_tag_group) + category.update!(allow_global_tags: true) + + open_append_modal + + topic_bulk_actions_modal.tag_selector.expand + topic_bulk_actions_modal.tag_selector.search(restricted_tag.name) + topic_bulk_actions_modal.tag_selector.select_row_by_value(restricted_tag.name) + topic_bulk_actions_modal.tag_selector.search(tag1.name) + topic_bulk_actions_modal.tag_selector.select_row_by_value(tag1.name) + + topic_bulk_actions_modal.click_bulk_topics_confirm + + expect( + find(topic_list.topic_list_item_class(topics.last)).find(".discourse-tags"), + ).to have_content(restricted_tag.name) + expect( + find(topic_list.topic_list_item_class(topics.last)).find(".discourse-tags"), + ).to have_content(tag1.name) + end + end + end + + context "when closing" do it "closes multiple topics" do sign_in(admin) 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 @@ -30,16 +126,15 @@ describe "Topic bulk select", type: :system do 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 + topic_list_header.click_bulk_button("close-topics") + expect(topic_bulk_actions_modal).to be_open # Closes the selected topics - topic_list_header.click_bulk_topics_confirm + topic_bulk_actions_modal.click_bulk_topics_confirm expect(topic_list).to have_closed_status(topics.first) end - it "closes topics normally" do + it "closes single topic" do # Watch the topic as a user sign_in(user) visit("/latest") @@ -54,8 +149,8 @@ describe "Topic bulk select", type: :system do topic_list_header.click_bulk_select_button topic_list.click_topic_checkbox(topics.third) topic_list_header.click_bulk_select_topics_dropdown - topic_list_header.click_close_topics_button - topic_list_header.click_bulk_topics_confirm + topic_list_header.click_bulk_button("close-topics") + topic_bulk_actions_modal.click_bulk_topics_confirm # Check that the user did receive a new post notification badge sign_in(user) @@ -78,9 +173,9 @@ describe "Topic bulk select", type: :system do topic_list_header.click_bulk_select_button topic_list.click_topic_checkbox(topics.first) topic_list_header.click_bulk_select_topics_dropdown - topic_list_header.click_close_topics_button - topic_list_header.click_silent # Check Silent - topic_list_header.click_bulk_topics_confirm + topic_list_header.click_bulk_button("close-topics") + topic_bulk_actions_modal.click_silent # Check Silent + topic_bulk_actions_modal.click_bulk_topics_confirm # Check that the user didn't receive a new post notification badge sign_in(user) @@ -96,15 +191,15 @@ describe "Topic bulk select", type: :system do topic_list_header.click_bulk_select_button topic_list.click_topic_checkbox(topics.first) topic_list_header.click_bulk_select_topics_dropdown - topic_list_header.click_close_topics_button + topic_list_header.click_bulk_button("close-topics") # Fill in message - topic_list_header.fill_in_close_note("My message") - topic_list_header.click_bulk_topics_confirm + topic_bulk_actions_modal.fill_in_close_note("None of these are useful") + topic_bulk_actions_modal.click_bulk_topics_confirm # Check that the topic now has the message visit("/t/#{topic.slug}/#{topic.id}") - expect(topic_page).to have_content("My message") + expect(topic_page).to have_content("None of these are useful") end it "works with keyboard shortcuts" do @@ -134,7 +229,7 @@ describe "Topic bulk select", type: :system do send_keys("x") send_keys([:shift, "d"]) - topic_list_header.click_dismiss_read_confirm + click_button("dismiss-read-confirm") expect(topic_list).to have_no_topics end