FEATURE: Allow selective dismissal of new and unread topics (#12976)

This PR improves the UI of bulk select so that its context is applied to the Dismiss Unread and Dismiss New buttons. Regular users (not just staff) are now able to use topic bulk selection on the /new and /unread routes to perform these dismiss actions more selectively.

For Dismiss Unread, there is a new count in the text of the button and in the modal when one or more topic is selected with the bulk select checkboxes.

For Dismiss New, there is a count in the button text, and we have added functionality to the server side to accept an array of topic ids to dismiss new for, instead of always having to dismiss all new, the same as the bulk dismiss unread functionality. To clean things up, the `DismissTopics` service has been rolled into the `TopicsBulkAction` service.

We now also show the top Dismiss/Dismiss New button based on whether the bottom one is in the viewport, not just based on the topic count.
This commit is contained in:
Martin Brennan 2021-05-26 09:38:46 +10:00 committed by GitHub
parent de0f2b9546
commit 7a79bd7da3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 399 additions and 292 deletions

View File

@ -1,5 +1,6 @@
import Component from "@ember/component";
import { schedule } from "@ember/runloop";
import { reads } from "@ember/object/computed";
import showModal from "discourse/lib/show-modal";
export default Component.extend({
@ -17,6 +18,8 @@ export default Component.extend({
});
},
canDoBulkActions: reads("currentUser.staff"),
actions: {
showBulkActions() {
const controller = showModal("topic-bulk-actions", {

View File

@ -0,0 +1,105 @@
import { action } from "@ember/object";
import showModal from "discourse/lib/show-modal";
import { later } from "@ember/runloop";
import isElementInViewport from "discourse/lib/is-element-in-viewport";
import discourseComputed, { on } from "discourse-common/utils/decorators";
import I18n from "I18n";
import Component from "@ember/component";
export default Component.extend({
tagName: "",
classNames: ["topic-dismiss-buttons"],
position: null,
selectedTopics: null,
model: null,
@discourseComputed("position")
containerClass(position) {
return `dismiss-container-${position}`;
},
@discourseComputed("position")
dismissReadId(position) {
return `dismiss-topics-${position}`;
},
@discourseComputed("position")
dismissNewId(position) {
return `dismiss-new-${position}`;
},
@discourseComputed(
"position",
"isOtherDismissUnreadButtonVisible",
"isOtherDismissNewButtonVisible"
)
showBasedOnPosition(
position,
isOtherDismissUnreadButtonVisible,
isOtherDismissNewButtonVisible
) {
if (position !== "top") {
return true;
}
return !(
isOtherDismissUnreadButtonVisible || isOtherDismissNewButtonVisible
);
},
@discourseComputed("selectedTopics.length")
dismissLabel(selectedTopicCount) {
if (selectedTopicCount === 0) {
return I18n.t("topics.bulk.dismiss_button");
}
return I18n.t("topics.bulk.dismiss_button_with_selected", {
count: selectedTopicCount,
});
},
@discourseComputed("selectedTopics.length")
dismissNewLabel(selectedTopicCount) {
if (selectedTopicCount === 0) {
return I18n.t("topics.bulk.dismiss_new");
}
return I18n.t("topics.bulk.dismiss_new_with_selected", {
count: selectedTopicCount,
});
},
// we want to only render the Dismiss... button at the top of the
// page if the user cannot see the bottom Dismiss... button based on their
// viewport, or if too many topics fill the page
@on("didInsertElement")
_determineOtherDismissVisibility() {
later(() => {
if (this.position === "top") {
this.set(
"isOtherDismissUnreadButtonVisible",
isElementInViewport(document.getElementById("dismiss-topics-bottom"))
);
this.set(
"isOtherDismissNewButtonVisible",
isElementInViewport(document.getElementById("dismiss-new-bottom"))
);
} else {
this.set("isOtherDismissUnreadButtonVisible", true);
this.set("isOtherDismissNewButtonVisible", true);
}
});
},
@action
dismissReadPosts() {
let dismissTitle = "topics.bulk.dismiss_read";
if (this.selectedTopics.length > 0) {
dismissTitle = "topics.bulk.dismiss_read_with_selected";
}
showModal("dismiss-read", {
titleTranslated: I18n.t(dismissTitle, {
count: this.selectedTopics.length,
}),
});
},
});

View File

@ -13,7 +13,7 @@ export default Component.extend({
if (path === "faq" || path === "guidelines") {
$(window).on("load.faq resize.faq scroll.faq", () => {
const faqUnread = !currentUser.get("read_faq");
if (faqUnread && isElementInViewport($(".contents p").last())) {
if (faqUnread && isElementInViewport($(".contents p").last()[0])) {
this.action();
}
});

View File

@ -18,7 +18,6 @@ import discourseComputed from "discourse-common/utils/decorators";
import { endWith } from "discourse/lib/computed";
import { routeAction } from "discourse/helpers/route-action";
import { inject as service } from "@ember/service";
import showModal from "discourse/lib/show-modal";
import { userPath } from "discourse/lib/url";
const controllerOpts = {
@ -39,6 +38,18 @@ const controllerOpts = {
order: readOnly("model.params.order"),
ascending: readOnly("model.params.ascending"),
selected: null,
@discourseComputed("model.filter", "model.topics.length")
showDismissRead(filter, topicsLength) {
return this._isFilterPage(filter, "unread") && topicsLength > 0;
},
@discourseComputed("model.filter", "model.topics.length")
showResetNew(filter, topicsLength) {
return this._isFilterPage(filter, "new") && topicsLength > 0;
},
actions: {
changeSort() {
deprecated(
@ -98,17 +109,20 @@ const controllerOpts = {
(this.router.currentRoute.queryParams["f"] ||
this.router.currentRoute.queryParams["filter"]) === "tracked";
Topic.resetNew(this.category, !this.noSubcategories, tracked).then(() =>
let topicIds = this.selected
? this.selected.map((topic) => topic.id)
: null;
Topic.resetNew(this.category, !this.noSubcategories, {
tracked,
topicIds,
}).then(() =>
this.send(
"refresh",
tracked ? { skipResettingParams: ["filter", "f"] } : {}
)
);
},
dismissReadPosts() {
showModal("dismiss-read", { title: "topics.bulk.dismiss_read" });
},
},
afterRefresh(filter, list, listModel = list) {
@ -122,32 +136,6 @@ const controllerOpts = {
this.send("loadingComplete");
},
isFilterPage: function (filter, filterType) {
if (!filter) {
return false;
}
return filter.match(new RegExp(filterType + "$", "gi")) ? true : false;
},
@discourseComputed("model.filter", "model.topics.length")
showDismissRead(filter, topicsLength) {
return this.isFilterPage(filter, "unread") && topicsLength > 0;
},
@discourseComputed("model.filter", "model.topics.length")
showResetNew(filter, topicsLength) {
return this.isFilterPage(filter, "new") && topicsLength > 0;
},
@discourseComputed("model.filter", "model.topics.length")
showDismissAtTop(filter, topicsLength) {
return (
(this.isFilterPage(filter, "new") ||
this.isFilterPage(filter, "unread")) &&
topicsLength >= 15
);
},
hasTopics: gt("model.topics.length", 0),
allLoaded: empty("model.more_topics_url"),
latest: endWith("model.filter", "latest"),

View File

@ -8,7 +8,6 @@ import Topic from "discourse/models/topic";
import { alias } from "@ember/object/computed";
import bootbox from "bootbox";
import { queryParams } from "discourse/controllers/discovery-sortable";
import showModal from "discourse/lib/show-modal";
export default Controller.extend(BulkTopicSelection, FilterModeMixin, {
application: controller(),
@ -93,48 +92,31 @@ export default Controller.extend(BulkTopicSelection, FilterModeMixin, {
}
},
isFilterPage: function (filter, filterType) {
if (!filter) {
return false;
}
return filter.match(new RegExp(filterType + "$", "gi")) ? true : false;
},
@discourseComputed("list.filter", "list.topics.length")
showDismissRead(filter, topicsLength) {
return this.isFilterPage(filter, "unread") && topicsLength > 0;
return this._isFilterPage(filter, "unread") && topicsLength > 0;
},
@discourseComputed("list.filter", "list.topics.length")
showResetNew(filter, topicsLength) {
return this.isFilterPage(filter, "new") && topicsLength > 0;
},
@discourseComputed("list.filter", "list.topics.length")
showDismissAtTop(filter, topicsLength) {
return (
(this.isFilterPage(filter, "new") ||
this.isFilterPage(filter, "unread")) &&
topicsLength >= 15
);
return this._isFilterPage(filter, "new") && topicsLength > 0;
},
actions: {
dismissReadPosts() {
showModal("dismiss-read", { title: "topics.bulk.dismiss_read" });
},
resetNew() {
const tracked =
(this.router.currentRoute.queryParams["f"] ||
this.router.currentRoute.queryParams["filter"]) === "tracked";
Topic.resetNew(
this.category,
!this.noSubcategories,
let topicIds = this.selected
? this.selected.map((topic) => topic.id)
: null;
Topic.resetNew(this.category, !this.noSubcategories, {
tracked,
this.tag
).then(() =>
tag: this.tag,
topicIds,
}).then(() =>
this.send(
"refresh",
tracked ? { skipResettingParams: ["filter", "f"] } : {}

View File

@ -1639,7 +1639,7 @@ export default Controller.extend(bufferedProperty("model"), {
function () {
const $post = $(`.topic-post article#post_${postNumber}`);
if ($post.length === 0 || isElementInViewport($post)) {
if ($post.length === 0 || isElementInViewport($post[0])) {
return;
}

View File

@ -1,6 +1,6 @@
export default function (element) {
if (element instanceof jQuery) {
element = element[0];
if (!element) {
return;
}
const $window = $(window),

View File

@ -41,6 +41,8 @@ export default function (name, opts) {
route.render(fullName, renderArgs);
if (opts.title) {
modalController.set("title", I18n.t(opts.title));
} else if (opts.titleTranslated) {
modalController.set("title", opts.titleTranslated);
} else {
modalController.set("title", null);
}

View File

@ -1,8 +1,8 @@
import Mixin from "@ember/object/mixin";
import { or } from "@ember/object/computed";
import { on } from "discourse-common/utils/decorators";
import { NotificationLevels } from "discourse/lib/notification-levels";
import Topic from "discourse/models/topic";
import { alias } from "@ember/object/computed";
import { on } from "discourse-common/utils/decorators";
import { inject as service } from "@ember/service";
export default Mixin.create({
@ -12,13 +12,20 @@ export default Mixin.create({
autoAddTopicsToBulkSelect: false,
selected: null,
canBulkSelect: alias("currentUser.staff"),
canBulkSelect: or("currentUser.staff", "showDismissRead", "showResetNew"),
@on("init")
resetSelected() {
this.set("selected", []);
},
_isFilterPage(filter, filterType) {
if (!filter) {
return false;
}
return new RegExp(filterType + "$", "gi").test(filter);
},
actions: {
toggleBulkSelect() {
this.toggleProperty("bulkSelectEnabled");

View File

@ -756,7 +756,14 @@ Topic.reopenClass({
});
},
resetNew(category, include_subcategories, tracked = false, tag = false) {
resetNew(category, include_subcategories, opts = {}) {
let { tracked, tag, topicIds } = {
tracked: false,
tag: null,
topicIds: null,
...opts,
};
const data = { tracked };
if (category) {
data.category_id = category.id;
@ -765,6 +772,9 @@ Topic.reopenClass({
if (tag) {
data.tag_id = tag.id;
}
if (topicIds) {
data.topic_ids = topicIds;
}
return ajax("/topics/reset-new", { type: "PUT", data });
},

View File

@ -1,5 +1,7 @@
{{#if selected}}
<div id="bulk-select">
{{d-button class="btn-default bulk-select-btn" action=(action "showBulkActions") icon="wrench"}}
</div>
{{#if canDoBulkActions}}
{{#if selected}}
<div id="bulk-select">
{{d-button class="btn-default bulk-select-btn" action=(action "showBulkActions") icon="wrench"}}
</div>
{{/if}}
{{/if}}

View File

@ -0,0 +1,21 @@
{{#if showBasedOnPosition}}
<div class="row {{containerClass}}">
{{#if showDismissRead}}
{{d-button
class="btn-default dismiss-read"
id=dismissReadId
action=(action "dismissReadPosts")
translatedLabel=dismissLabel
title="topics.bulk.dismiss_tooltip"}}
{{/if}}
{{#if showResetNew}}
{{d-button
class="btn-default dismiss-read"
id=dismissNewId
action=resetNew
icon="check"
translatedLabel=dismissNewLabel}}
{{/if}}
</div>
{{/if}}

View File

@ -2,26 +2,8 @@
<div class="alert alert-info">{{redirectedReason}}</div>
{{/if}}
{{#if showDismissAtTop}}
<div class="row dismiss-container-top">
{{#if showDismissRead}}
{{d-button
class="btn-default dismiss-read"
id="dismiss-topics-top"
action=(action "dismissReadPosts")
title="topics.bulk.dismiss_tooltip"
label="topics.bulk.dismiss_button"}}
{{/if}}
{{#if showResetNew}}
{{d-button
class="btn-default dismiss-read"
id="dismiss-new-top"
action=(action "resetNew")
icon="check"
label="topics.bulk.dismiss_new"}}
{{/if}}
</div>
{{/if}}
{{topic-dismiss-buttons position="top" selectedTopics=selected
model=model showResetNew=showResetNew showDismissRead=showDismissRead resetNew=(action "resetNew")}}
{{#if model.sharedDrafts}}
{{topic-list
@ -89,22 +71,8 @@
<footer class="topic-list-bottom">
{{conditional-loading-spinner condition=model.loadingMore}}
{{#if allLoaded}}
{{#if showDismissRead}}
{{d-button
class="btn-default dismiss-read"
id="dismiss-topics"
action=(action "dismissReadPosts")
title="topics.bulk.dismiss_tooltip"
label="topics.bulk.dismiss_button"}}
{{/if}}
{{#if showResetNew}}
{{d-button
class="btn-default dismiss-read"
action=(action "resetNew")
id="dismiss-new"
icon="check"
label="topics.bulk.dismiss_new"}}
{{/if}}
{{topic-dismiss-buttons position="bottom" selectedTopics=selected
model=model showResetNew=showResetNew showDismissRead=showDismissRead resetNew=(action "resetNew")}}
{{#footer-message education=footerEducation message=footerMessage}}
{{#if latest}}

View File

@ -1,23 +1,5 @@
{{#if showDismissAtTop}}
<div class="row dismiss-container-top">
{{#if showDismissRead}}
{{d-button
class="btn-default dismiss-read"
id="dismiss-topics-top"
action=(action "dismissReadPosts")
title="topics.bulk.dismiss_tooltip"
label="topics.bulk.dismiss_button"}}
{{/if}}
{{#if showResetNew}}
{{d-button
class="btn-default dismiss-read"
id="dismiss-new-top"
action=(action "resetNew")
icon="check"
label="topics.bulk.dismiss_new"}}
{{/if}}
</div>
{{/if}}
{{topic-dismiss-buttons position="top" selectedTopics=selected
model=model showResetNew=showResetNew showDismissRead=showDismissRead resetNew=(action "resetNew")}}
{{#discovery-topics-list model=model refresh=(action "refresh") incomingCount=topicTrackingState.incomingCount as |discoveryTopicList|}}
{{#if top}}
@ -51,22 +33,8 @@
<footer class="topic-list-bottom">
{{conditional-loading-spinner condition=model.loadingMore}}
{{#if allLoaded}}
{{#if showDismissRead}}
{{d-button
class="btn-default dismiss-read"
id="dismiss-topics"
action=(action "dismissReadPosts")
title="topics.bulk.dismiss_tooltip"
label="topics.bulk.dismiss_button"}}
{{/if}}
{{#if showResetNew}}
{{d-button
class="btn-default dismiss-read"
id="dismiss-new"
action=(action "resetNew")
icon="check"
label="topics.bulk.dismiss_new"}}
{{/if}}
{{topic-dismiss-buttons position="bottom" selectedTopics=selected
model=model showResetNew=showResetNew showDismissRead=showDismissRead resetNew=(action "resetNew")}}
{{#footer-message education=footerEducation message=footerMessage}}
{{#if latest}}

View File

@ -37,29 +37,8 @@
{{plugin-outlet name="discovery-list-container-top" args=(hash category=category)}}
{{#if showDismissAtTop}}
<div class="row dismiss-container-top">
{{#if showDismissRead}}
{{d-button
class="btn-default dismiss-read"
id="dismiss-topics"
action=(action "dismissReadPosts")
title="topics.bulk.dismiss_tooltip"
label="topics.bulk.dismiss_button"
}}
{{/if}}
{{#if showResetNew}}
{{d-button
class="btn-default dismiss-read"
action=(action "resetNew")
id="dismiss-new"
icon="check"
label="topics.bulk.dismiss_new"
}}
{{/if}}
</div>
{{/if}}
{{topic-dismiss-buttons position="top" selectedTopics=selected
model=model showResetNew=showResetNew showDismissRead=showDismissRead resetNew=(action "resetNew")}}
<div class="container list-container">
<div class="row">
@ -99,25 +78,8 @@
{{/if}}
<footer class="topic-list-bottom">
{{#if showDismissRead}}
{{d-button
class="btn-default dismiss-read"
id="dismiss-topics"
action=(action "dismissReadPosts")
title="topics.bulk.dismiss_tooltip"
label="topics.bulk.dismiss_button"
}}
{{/if}}
{{#if showResetNew}}
{{d-button
class="btn-default dismiss-read"
action=(action "resetNew")
id="dismiss-new"
icon="check"
label="topics.bulk.dismiss_new"
}}
{{/if}}
{{topic-dismiss-buttons position="bottom" selectedTopics=selected
model=model showResetNew=showResetNew showDismissRead=showDismissRead resetNew=(action "resetNew")}}
{{#footer-message education=footerEducation message=footerMessage}}
{{#link-to "tags"}} {{i18n "topic.browse_all_tags"}}{{/link-to}} {{i18n "or"}} {{#link-to "discovery.latest"}}{{i18n "topic.view_latest_topics"}}{{/link-to}}.

View File

@ -245,11 +245,11 @@ class TopicsController < ApplicationController
params.require(:topic_id)
params.require(:post_ids)
post_ids = params[:post_ids].map(&:to_i)
unless Array === post_ids
unless Array === params[:post_ids]
render_json_error("Expecting post_ids to contain a list of posts ids")
return
end
post_ids = params[:post_ids].map(&:to_i)
if post_ids.length > 100
render_json_error("Requested a chunk that is too big")
@ -911,6 +911,11 @@ class TopicsController < ApplicationController
def bulk
if params[:topic_ids].present?
unless Array === params[:topic_ids]
raise Discourse::InvalidParameters.new(
"Expecting topic_ids to contain a list of topic ids"
)
end
topic_ids = params[:topic_ids].map { |t| t.to_i }
elsif params[:filter] == 'unread'
tq = TopicQuery.new(current_user)
@ -970,7 +975,18 @@ class TopicsController < ApplicationController
end
end
dismissed_topic_ids = DismissTopics.new(current_user, topic_scope).perform!
if params[:topic_ids].present?
unless Array === params[:topic_ids]
raise Discourse::InvalidParameters.new(
"Expecting topic_ids to contain a list of topic ids"
)
end
topic_ids = params[:topic_ids].map { |t| t.to_i }
topic_scope = topic_scope.where(id: topic_ids)
end
dismissed_topic_ids = TopicsBulkAction.new(current_user, [topic_scope.pluck(:id)], type: "dismiss_topics").perform!
TopicTrackingState.publish_dismiss_new(current_user.id, topic_ids: dismissed_topic_ids)
render body: nil

View File

@ -1,45 +0,0 @@
# frozen_string_literal: true
class DismissTopics
def initialize(user, topics_scope)
@user = user
@topics_scope = topics_scope
end
def perform!
DismissedTopicUser.insert_all(rows) if rows.present?
@rows.map { |row| row[:topic_id] }
end
private
def rows
@rows ||= @topics_scope
.joins("LEFT JOIN topic_users ON topic_users.topic_id = topics.id AND topic_users.user_id = #{@user.id}")
.where("topics.created_at >= ?", since_date)
.where("topic_users.last_read_post_number IS NULL")
.where("topics.archetype <> ?", Archetype.private_message)
.order("topics.created_at DESC")
.limit(SiteSetting.max_new_topics).map do |topic|
{
topic_id: topic.id,
user_id: @user.id,
created_at: Time.zone.now
}
end
end
def since_date
new_topic_duration_minutes = @user.user_option&.new_topic_duration_minutes || SiteSetting.default_other_new_topic_duration_minutes
setting_date =
case new_topic_duration_minutes
when User::NewTopicDuration::LAST_VISIT
@user.previous_visit_at || @user.created_at
when User::NewTopicDuration::ALWAYS
@user.created_at
else
new_topic_duration_minutes.minutes.ago
end
[setting_date, @user.created_at, Time.at(SiteSetting.min_new_topics_time).to_datetime].max
end
end

View File

@ -2332,10 +2332,13 @@ en:
delete: "Delete Topics"
dismiss: "Dismiss"
dismiss_read: "Dismiss all unread"
dismiss_read_with_selected: "Dismiss %{count} unread"
dismiss_button: "Dismiss…"
dismiss_button_with_selected: "Dismiss (%{count})…"
dismiss_tooltip: "Dismiss just new posts or stop tracking topics"
also_dismiss_topics: "Stop tracking these topics so they never show up as unread for me again"
dismiss_new: "Dismiss New"
dismiss_new_with_selected: "Dismiss New (%{count})"
toggle: "toggle bulk selection of topics"
actions: "Bulk Actions"
change_category: "Set Category"

View File

@ -14,7 +14,7 @@ class TopicsBulkAction
@operations ||= %w(change_category close archive change_notification_level
reset_read dismiss_posts delete unlist archive_messages
move_messages_to_inbox change_tags append_tags remove_tags
relist)
relist dismiss_topics)
end
def self.register_operation(name, &block)
@ -26,7 +26,7 @@ class TopicsBulkAction
raise Discourse::InvalidParameters.new(:operation) unless TopicsBulkAction.operations.include?(@operation[:type])
# careful these are private methods, we need send
send(@operation[:type])
@changed_ids
@changed_ids.sort
end
private
@ -81,6 +81,24 @@ class TopicsBulkAction
@changed_ids.concat @topic_ids
end
def dismiss_topics
rows = Topic.where(id: @topic_ids)
.joins("LEFT JOIN topic_users ON topic_users.topic_id = topics.id AND topic_users.user_id = #{@user.id}")
.where("topics.created_at >= ?", dismiss_topics_since_date)
.where("topic_users.last_read_post_number IS NULL")
.where("topics.archetype <> ?", Archetype.private_message)
.order("topics.created_at DESC")
.limit(SiteSetting.max_new_topics).map do |topic|
{
topic_id: topic.id,
user_id: @user.id,
created_at: Time.zone.now
}
end
DismissedTopicUser.insert_all(rows) if rows.present?
@changed_ids = rows.map { |row| row[:topic_id] }
end
def reset_read
PostTiming.destroy_for(@user.id, @topic_ids)
end
@ -211,4 +229,18 @@ class TopicsBulkAction
@topics ||= Topic.where(id: @topic_ids)
end
def dismiss_topics_since_date
new_topic_duration_minutes = @user.user_option&.new_topic_duration_minutes || SiteSetting.default_other_new_topic_duration_minutes
setting_date =
case new_topic_duration_minutes
when User::NewTopicDuration::LAST_VISIT
@user.previous_visit_at || @user.created_at
when User::NewTopicDuration::ALWAYS
@user.created_at
else
new_topic_duration_minutes.minutes.ago
end
[setting_date, @user.created_at, Time.at(SiteSetting.min_new_topics_time).to_datetime].max
end
end

View File

@ -5,6 +5,70 @@ require 'rails_helper'
describe TopicsBulkAction do
fab!(:topic) { Fabricate(:topic) }
describe type: "dismiss_topics" do
fab!(:user) { Fabricate(:user, created_at: 1.days.ago) }
fab!(:category) { Fabricate(:category) }
fab!(:topic2) { Fabricate(:topic, category: category, created_at: 60.minutes.ago) }
fab!(:topic3) { Fabricate(:topic, category: category, created_at: 120.minutes.ago) }
before do
topic.destroy!
end
it 'dismisses two topics' do
expect { TopicsBulkAction.new(user, [Topic.all.pluck(:id)], type: "dismiss_topics").perform! }.to change { DismissedTopicUser.count }.by(2)
end
it 'returns dismissed topic ids' do
expect(TopicsBulkAction.new(user, [Topic.all.pluck(:id)], type: "dismiss_topics").perform!.sort).to match_array(
[topic2.id, topic3.id]
)
end
it 'respects max_new_topics limit' do
SiteSetting.max_new_topics = 1
expect do
TopicsBulkAction.new(user, [Topic.all.pluck(:id)], type: "dismiss_topics").perform!
end.to change { DismissedTopicUser.count }.by(1)
dismissed_topic_user = DismissedTopicUser.last
expect(dismissed_topic_user.user_id).to eq(user.id)
expect(dismissed_topic_user.topic_id).to eq(topic2.id)
expect(dismissed_topic_user.created_at).not_to be_nil
end
it 'respects seen topics' do
Fabricate(:topic_user, user: user, topic: topic2, last_read_post_number: 1)
Fabricate(:topic_user, user: user, topic: topic3, last_read_post_number: 1)
expect do
TopicsBulkAction.new(user, [Topic.all.pluck(:id)], type: "dismiss_topics").perform!
end.to change { DismissedTopicUser.count }.by(0)
end
it 'dismisses when topic user without last_read_post_number' do
Fabricate(:topic_user, user: user, topic: topic2, last_read_post_number: nil)
Fabricate(:topic_user, user: user, topic: topic3, last_read_post_number: nil)
expect do
TopicsBulkAction.new(user, [Topic.all.pluck(:id)], type: "dismiss_topics").perform!
end.to change { DismissedTopicUser.count }.by(2)
end
it 'respects new_topic_duration_minutes' do
user.user_option.update!(new_topic_duration_minutes: 70)
expect do
TopicsBulkAction.new(user, [Topic.all.pluck(:id)], type: "dismiss_topics").perform!
end.to change { DismissedTopicUser.count }.by(1)
dismissed_topic_user = DismissedTopicUser.last
expect(dismissed_topic_user.user_id).to eq(user.id)
expect(dismissed_topic_user.topic_id).to eq(topic2.id)
expect(dismissed_topic_user.created_at).not_to be_nil
end
end
describe "dismiss_posts" do
it "dismisses posts" do
post1 = create_post

View File

@ -2801,6 +2801,17 @@ RSpec.describe TopicsController do
}
end
it "raises an error if topic_ids is provided and it is not an array" do
put "/topics/bulk.json", params: {
topic_ids: "1", operation: operation
}
expect(response.parsed_body["errors"].first).to match(/Expecting topic_ids to contain a list/)
put "/topics/bulk.json", params: {
topic_ids: [1], operation: operation
}
expect(response.parsed_body["errors"]).to eq(nil)
end
it "respects the tracked parameter" do
# untracked topic
CategoryUser.set_notification_level_for_category(user,
@ -2975,7 +2986,7 @@ RSpec.describe TopicsController do
it 'dismisses topics for main category and subcategories' do
sign_in(user)
TopicTrackingState.expects(:publish_dismiss_new).with(user.id, topic_ids: [subcategory_topic.id, category_topic.id])
TopicTrackingState.expects(:publish_dismiss_new).with(user.id, topic_ids: [category_topic.id, subcategory_topic.id])
put "/topics/reset-new.json?category_id=#{category.id}&include_subcategories=true"
@ -3011,6 +3022,69 @@ RSpec.describe TopicsController do
expect(DismissedTopicUser.where(user_id: user.id).pluck(:topic_id)).to eq([tag_and_category_topic.id])
end
end
context "specific topics" do
fab!(:topic2) { Fabricate(:topic) }
fab!(:topic3) { Fabricate(:topic) }
it "updates the `new_since` date" do
sign_in(user)
old_date = 2.years.ago
user.user_stat.update_column(:new_since, old_date)
user.update_column(:created_at, old_date)
TopicTrackingState.expects(:publish_dismiss_new).with(user.id, topic_ids: [topic2.id, topic3.id]).at_least_once
put "/topics/reset-new.json", { params: { topic_ids: [topic2.id, topic3.id] } }
expect(response.status).to eq(200)
user.reload
expect(user.user_stat.new_since.to_date).not_to eq(old_date.to_date)
expect(DismissedTopicUser.where(user_id: user.id).pluck(:topic_id)).to match_array([topic2.id, topic3.id])
end
it "raises an error if topic_ids is provided and it is not an array" do
sign_in(user)
put "/topics/reset-new.json", params: { topic_ids: topic2.id }
expect(response.parsed_body["errors"].first).to match(/Expecting topic_ids to contain a list/)
put "/topics/reset-new.json", params: { topic_ids: [topic2.id] }
expect(response.parsed_body["errors"]).to eq(nil)
end
describe "when tracked param is true" do
it "does not update user_stat.new_since and does not dismiss untracked topics" do
sign_in(user)
old_date = 2.years.ago
user.user_stat.update_column(:new_since, old_date)
put "/topics/reset-new.json?tracked=true", { params: { topic_ids: [topic2.id, topic3.id] } }
expect(response.status).to eq(200)
user.reload
expect(user.user_stat.new_since.to_date).to eq(old_date.to_date)
expect(DismissedTopicUser.where(user_id: user.id).pluck(:topic_id)).to be_empty
end
it "creates topic user records for each unread topic" do
sign_in(user)
user.user_stat.update_column(:new_since, 2.years.ago)
tracked_category = Fabricate(:category)
CategoryUser.set_notification_level_for_category(user,
NotificationLevels.all[:tracking],
tracked_category.id)
tracked_topic = create_post.topic
tracked_topic.update!(category_id: tracked_category.id)
topic2.update!(category_id: tracked_category.id)
create_post # This is a new post, but is not tracked so a record will not be created for it
expect do
put "/topics/reset-new.json?tracked=true", { params: { topic_ids: [tracked_topic.id, topic2.id, topic3.id] } }
end.to change { DismissedTopicUser.where(user_id: user.id).count }.by(2)
expect(DismissedTopicUser.where(user_id: user.id).pluck(:topic_id)).to match_array([tracked_topic.id, topic2.id])
end
end
end
end
describe '#feature_stats' do

View File

@ -1,55 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
describe DismissTopics do
fab!(:user) { Fabricate(:user, created_at: 1.days.ago) }
fab!(:category) { Fabricate(:category) }
fab!(:topic1) { Fabricate(:topic, category: category, created_at: 60.minutes.ago) }
fab!(:topic2) { Fabricate(:topic, category: category, created_at: 120.minutes.ago) }
describe '#perform!' do
it 'dismisses two topics' do
expect { described_class.new(user, Topic.all).perform! }.to change { DismissedTopicUser.count }.by(2)
end
it 'returns dismissed topic ids' do
expect(described_class.new(user, Topic.all).perform!.sort).to eq([topic1.id, topic2.id])
end
it 'respects max_new_topics limit' do
SiteSetting.max_new_topics = 1
expect { described_class.new(user, Topic.all).perform! }.to change { DismissedTopicUser.count }.by(1)
dismissed_topic_user = DismissedTopicUser.last
expect(dismissed_topic_user.user_id).to eq(user.id)
expect(dismissed_topic_user.topic_id).to eq(topic1.id)
expect(dismissed_topic_user.created_at).not_to be_nil
end
it 'respects seen topics' do
Fabricate(:topic_user, user: user, topic: topic1, last_read_post_number: 1)
Fabricate(:topic_user, user: user, topic: topic2, last_read_post_number: 1)
expect { described_class.new(user, Topic.all).perform! }.to change { DismissedTopicUser.count }.by(0)
end
it 'dismisses when topic user without last_read_post_number' do
Fabricate(:topic_user, user: user, topic: topic1, last_read_post_number: nil)
Fabricate(:topic_user, user: user, topic: topic2, last_read_post_number: nil)
expect { described_class.new(user, Topic.all).perform! }.to change { DismissedTopicUser.count }.by(2)
end
it 'respects new_topic_duration_minutes' do
user.user_option.update!(new_topic_duration_minutes: 70)
expect { described_class.new(user, Topic.all).perform! }.to change { DismissedTopicUser.count }.by(1)
dismissed_topic_user = DismissedTopicUser.last
expect(dismissed_topic_user.user_id).to eq(user.id)
expect(dismissed_topic_user.topic_id).to eq(topic1.id)
expect(dismissed_topic_user.created_at).not_to be_nil
end
end
end