FEATURE: New share topic modal (#12804)

The old share modal used to host both share and invite functionality,
under two tabs. The new "Share Topic" modal can be used only for
sharing, but has a link to the invite modal.

Among the sharing methods, there is also "Notify" which points out
that existing users will simply be notified (this was not clear
before). Staff members can notify as many users as they want, but
regular users are restricted to one at a time, no more than
max_topic_invitations_per_day. The user will not receive another
notification if they have been notified of the same topic in past hour.

The "Create Invite" modal also suffered some changes: the two radio
boxes for selecting the type (invite or email) have been replaced by a
single checkbox (is email?) and then the two labels about emails have
been replaced by a single one, some fields were reordered and the
advanced options toggle was moved to the bottom right of the modal.
This commit is contained in:
Dan Ungureanu 2021-04-23 19:18:23 +03:00 committed by GitHub
parent e3b1d1a718
commit cfee2728ce
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 444 additions and 274 deletions

View File

@ -30,6 +30,8 @@ export default Controller.extend(
});
this.setProperties({
invite: null,
invites: null,
autogenerated: false,
showAdvanced: false,
});
@ -41,7 +43,7 @@ export default Controller.extend(
if (this.autogenerated) {
this.invite
.destroy()
.then(() => this.invites.removeObject(this.invite));
.then(() => this.invites && this.invites.removeObject(this.invite));
}
},
@ -53,7 +55,7 @@ export default Controller.extend(
},
setAutogenerated(value) {
if ((this.autogenerated || !this.invite.id) && !value) {
if (this.invites && (this.autogenerated || !this.invite.id) && !value) {
this.invites.unshiftObject(this.invite);
}
@ -168,6 +170,15 @@ export default Controller.extend(
this.save({ sendEmail: false, copy: true });
},
@action
toggleLimitToEmail() {
const limitToEmail = !this.limitToEmail;
this.setProperties({
limitToEmail,
type: limitToEmail ? "email" : "link",
});
},
@action
saveInvite(sendEmail) {
this.appEvents.trigger("modal-body:clearFlash");
@ -181,5 +192,10 @@ export default Controller.extend(
this.set("buffered.email", result[0].email[0]);
});
},
@action
toggleAdvanced() {
this.toggleProperty("showAdvanced");
},
}
);

View File

@ -0,0 +1,116 @@
import Controller from "@ember/controller";
import { action } from "@ember/object";
import { getAbsoluteURL } from "discourse-common/lib/get-url";
import discourseComputed from "discourse-common/utils/decorators";
import { ajax } from "discourse/lib/ajax";
import { extractError } from "discourse/lib/ajax-error";
import Sharing from "discourse/lib/sharing";
import showModal from "discourse/lib/show-modal";
import { bufferedProperty } from "discourse/mixins/buffered-content";
import ModalFunctionality from "discourse/mixins/modal-functionality";
import I18n from "I18n";
export default Controller.extend(
ModalFunctionality,
bufferedProperty("invite"),
{
onShow() {
this.set("showNotifyUsers", false);
},
@discourseComputed("topic.shareUrl")
topicUrl(url) {
return url ? getAbsoluteURL(url) : null;
},
@discourseComputed(
"topic.{isPrivateMessage,invisible,category.read_restricted}"
)
sources(topic) {
const privateContext =
this.siteSettings.login_required ||
(topic && topic.isPrivateMessage) ||
(topic && topic.invisible) ||
topic.category.read_restricted;
return Sharing.activeSources(
this.siteSettings.share_links,
privateContext
);
},
@action
copied() {
return this.appEvents.trigger("modal-body:flash", {
text: I18n.t("topic.share.copied"),
messageClass: "success",
});
},
@action
onChangeUsers(usernames) {
this.set("users", usernames.uniq());
},
@action
share(source) {
this.set("showNotifyUsers", false);
Sharing.shareSource(source, {
title: this.topic.title,
url: this.topicUrl,
});
},
@action
toggleNotifyUsers() {
if (this.showNotifyUsers) {
this.set("showNotifyUsers", false);
} else {
this.setProperties({
showNotifyUsers: true,
users: [],
});
}
},
@action
notifyUsers() {
if (this.users.length === 0) {
return;
}
ajax(`/t/${this.topic.id}/invite-notify`, {
type: "POST",
data: { usernames: this.users },
})
.then(() => {
this.setProperties({ showNotifyUsers: false });
this.appEvents.trigger("modal-body:flash", {
text: I18n.t("topic.share.notify_users.success", {
count: this.users.length,
username: this.users[0],
}),
messageClass: "success",
});
})
.catch((error) => {
this.appEvents.trigger("modal-body:flash", {
text: extractError(error),
messageClass: "error",
});
});
},
@action
inviteUsers() {
this.set("showNotifyUsers", false);
const controller = showModal("create-invite");
controller.set("showAdvanced", true);
controller.buffered.setProperties({
topicId: this.topic.id,
topicTitle: this.topic.title,
});
controller.save({ autogenerated: true });
},
}
);

View File

@ -25,39 +25,10 @@ export default {
},
title: "topic.share.help",
action() {
const panels = [
{
id: "share",
title: "topic.share.extended_title",
model: {
topic: this.topic,
},
},
];
if (this.canInviteTo && !this.inviteDisabled) {
let invitePanelTitle;
if (this.isPM) {
invitePanelTitle = "topic.invite_private.title";
} else if (this.invitingToTopic) {
invitePanelTitle = "topic.invite_reply.title";
} else {
invitePanelTitle = "user.invited.create";
}
panels.push({
id: "invite",
title: invitePanelTitle,
model: {
inviteModel: this.topic,
},
});
}
showModal("share-and-invite", {
modalClass: "share-and-invite",
panels,
const controller = showModal("share-topic");
controller.setProperties({
allowInvites: this.canInviteTo && !this.inviteDisabled,
topic: this.topic,
});
},
dropdown() {

View File

@ -16,15 +16,25 @@
<p>{{expiresAtLabel}}</p>
<div class="input-group invite-type">
<div class="radio-group">
{{radio-button id="invite-type-link" name="invite-type" value="link" selection=type}}
<label for="invite-type-link">{{i18n "user.invited.invite.type_link"}}</label>
</div>
{{input type="checkbox" id="invite-type" checked=limitToEmail click=(action "toggleLimitToEmail")}}
<label for="invite-type">{{i18n "user.invited.invite.restrict_email"}}</label>
<div class="radio-group">
{{radio-button id="invite-type-email" name="invite-type" value="email" selection=type}}
<label for="invite-type-email">{{i18n "user.invited.invite.type_email"}}</label>
</div>
{{#if isEmail}}
<div class="invite-input-with-button">
{{input
id="invite-email"
value=buffered.email
placeholderKey="topic.invite_reply.email_placeholder"
}}
{{#if capabilities.hasContactPicker}}
{{d-button
icon="address-book"
action=(action "searchContact")
class="btn-primary open-contact-picker"
}}
{{/if}}
</div>
{{/if}}
</div>
{{#if isLink}}
@ -41,34 +51,28 @@
{{/if}}
{{#if isEmail}}
<div class="input-group invite-email">
<label for="invite-email">{{i18n "user.invited.invite.email"}}</label>
<div class="invite-input-with-button">
{{input
id="invite-email"
value=buffered.email
placeholderKey="topic.invite_reply.email_placeholder"
}}
{{#if capabilities.hasContactPicker}}
{{d-button
icon="address-book"
action=(action "searchContact")
class="btn-primary open-contact-picker"
}}
{{/if}}
{{#if showAdvanced}}
<div class="input-group invite-custom-message">
<label for="invite-message">{{i18n "user.invited.invite.custom_message"}}</label>
{{textarea id="invite-message" value=buffered.custom_message}}
</div>
</div>
{{/if}}
{{/if}}
{{#if currentUser.staff}}
<p id="invite-show-advanced">
{{#if showAdvanced}}
<a href {{action (mut showAdvanced) false}}>{{d-icon "caret-down"}} {{i18n "user.invited.invite.hide_advanced"}}</a>
{{else}}
<a href {{action (mut showAdvanced) true}}>{{d-icon "caret-right"}} {{i18n "user.invited.invite.show_advanced"}}</a>
{{/if}}
</p>
{{#if showAdvanced}}
<div class="input-group invite-to-topic">
{{choose-topic
selectedTopicId=buffered.topicId
topicTitle=buffered.topicTitle
additionalFilters="status:public"
label="user.invited.invite.invite_to_topic"
}}
</div>
{{/if}}
{{/if}}
{{#if currentUser.staff}}
{{#if showAdvanced}}
<div class="input-group invite-to-groups">
<label>{{i18n "user.invited.invite.add_to_groups"}}</label>
@ -79,16 +83,11 @@
onChange=(action (mut buffered.groupIds))
}}
</div>
{{/if}}
{{/if}}
<div class="input-group invite-to-topic">
{{choose-topic
selectedTopicId=buffered.topicId
topicTitle=buffered.topicTitle
additionalFilters="status:public"
label="user.invited.invite.invite_to_topic"
}}
</div>
{{#if currentUser.staff}}
{{#if showAdvanced}}
<div class="input-group invite-expires-at">
{{future-date-input
displayLabel=(i18n "user.invited.invite.expires_at")
@ -98,13 +97,6 @@
onChangeInput=(action (mut buffered.expires_at))
}}
</div>
{{#if isEmail}}
<div class="input-group invite-custom-message">
<label for="invite-message">{{i18n "user.invited.invite.custom_message"}}</label>
{{textarea id="invite-message" value=buffered.custom_message}}
</div>
{{/if}}
{{/if}}
{{/if}}
</form>
@ -130,8 +122,9 @@
{{/if}}
{{d-button
label="cancel"
class="btn-flat"
action=(route-action "closeModal")
action=(action "toggleAdvanced")
class="show-advanced"
icon="cog"
title=(if showAdvanced "user.invited.invite.hide_advanced" "user.invited.invite.show_advanced")
}}
</div>

View File

@ -0,0 +1,61 @@
{{#d-modal-body title="topic.share.title"}}
<form>
<div class="input-group invite-link">
<label for="invite-link">{{i18n "topic.share.instructions"}}</label>
<div class="invite-input-with-button">
{{input
name="invite-link"
class="invite-link"
value=topicUrl
readonly=true
}}
{{copy-button selector="input.invite-link" copied=(action "copied")}}
</div>
</div>
<div class="sources">
{{#each sources as |s|}}
{{share-source source=s title=topic.title action=(action "share")}}
{{/each}}
{{d-button
class="btn-primary"
label="topic.share.notify_users.title"
icon="users"
action=(action "toggleNotifyUsers")
}}
{{#if allowInvites}}
{{d-button
class="btn-primary"
label="topic.share.invite_users"
icon="user-plus"
action=(action "inviteUsers")
}}
{{/if}}
</div>
{{#if showNotifyUsers}}
<div class="input-group invite-users">
<label for="invite-users">{{i18n "topic.share.notify_users.instructions"}}</label>
<div class="invite-input-with-button">
{{user-chooser
value=users
onChange=(action "onChangeUsers")
options=(hash
topicId=topic.id
maximum=(unless currentUser.staff 1)
excludeCurrentUser=true
)
}}
{{d-button
icon="check"
class="btn-primary"
disabled=(not users)
action=(action "notifyUsers")
}}
</div>
</div>
{{/if}}
</form>
{{/d-modal-body}}

View File

@ -43,7 +43,7 @@ acceptance("Invites - Create & Edit Invite Modal", function (needs) {
"shows an invite link when modal is opened"
);
await click("#invite-show-advanced a");
await click(".modal-footer .show-advanced");
await assert.ok(
find(".invite-to-groups").length > 0,
"shows advanced options"
@ -57,7 +57,7 @@ acceptance("Invites - Create & Edit Invite Modal", function (needs) {
"shows advanced options"
);
await click(".modal-footer .btn:last-child");
await click(".modal-close");
assert.ok(deleted, "deletes the invite if not saved");
});
@ -77,7 +77,7 @@ acceptance("Invites - Create & Edit Invite Modal", function (needs) {
"adds invite to list after saving"
);
await click(".modal-footer .btn:last-child");
await click(".modal-close");
assert.notOk(deleted, "does not delete invite on close");
});
@ -87,7 +87,7 @@ acceptance("Invites - Create & Edit Invite Modal", function (needs) {
await click(".invite-link .btn");
await click(".modal-footer .btn:last-child");
await click(".modal-close");
assert.notOk(deleted, "does not delete invite on close");
});
@ -95,7 +95,7 @@ acceptance("Invites - Create & Edit Invite Modal", function (needs) {
await visit("/u/eviltrout/invited/pending");
await click(".invite-controls .btn:first-child");
await click("#invite-type-email");
await click("#invite-type");
await click(".invite-link .btn");
assert.equal(
find("#modal-alert").text(),
@ -130,7 +130,6 @@ acceptance("Invites - Link Invites", function (needs) {
await visit("/u/eviltrout/invited/pending");
await click(".invite-controls .btn:first-child");
await click("#invite-type-link");
assert.ok(
find("#invite-max-redemptions").length,
"shows max redemptions field"
@ -173,7 +172,7 @@ acceptance("Invites - Email Invites", function (needs) {
await visit("/u/eviltrout/invited/pending");
await click(".invite-controls .btn:first-child");
await click("#invite-type-email");
await click("#invite-type");
assert.ok(find("#invite-email").length, "shows email field");

View File

@ -1,96 +0,0 @@
import {
acceptance,
exists,
queryAll,
} from "discourse/tests/helpers/qunit-helpers";
import { click, visit } from "@ember/test-helpers";
import { test } from "qunit";
acceptance("Share and Invite modal - desktop", function (needs) {
needs.user();
test("Topic footer button", async function (assert) {
await visit("/t/internationalization-localization/280");
assert.ok(
exists("#topic-footer-button-share-and-invite"),
"the button exists"
);
await click("#topic-footer-button-share-and-invite");
assert.ok(exists(".share-and-invite.modal"), "it shows the modal");
assert.ok(
exists(".share-and-invite.modal .modal-tab.share"),
"it shows the share tab"
);
assert.ok(
exists(".share-and-invite.modal .modal-tab.share.is-active"),
"it activates the share tab by default"
);
assert.ok(
exists(".share-and-invite.modal .modal-tab.invite"),
"it shows the invite tab"
);
assert.equal(
queryAll(".share-and-invite.modal .modal-panel.share .title").text(),
"Topic: Internationalization / localization",
"it shows the topic title"
);
assert.ok(
queryAll(".share-and-invite.modal .modal-panel.share .topic-share-url")
.val()
.includes("/t/internationalization-localization/280?u=eviltrout"),
"it shows the topic sharing url"
);
assert.ok(
queryAll(".share-and-invite.modal .social-link").length > 1,
"it shows social sources"
);
await click(".share-and-invite.modal .modal-tab.invite");
assert.ok(
exists(
".share-and-invite.modal .modal-panel.invite .send-invite:disabled"
),
"send invite button is disabled"
);
assert.ok(
exists(
".share-and-invite.modal .modal-panel.invite .generate-invite-link:disabled"
),
"generate invite button is disabled"
);
});
test("Post date link", async function (assert) {
await visit("/t/internationalization-localization/280");
await click("#post_2 .post-info.post-date a");
assert.ok(exists("#share-link"), "it shows the share modal");
});
});
acceptance("Share url with badges disabled - desktop", function (needs) {
needs.user();
needs.settings({ enable_badges: false });
test("topic footer button - badges disabled - desktop", async function (assert) {
await visit("/t/internationalization-localization/280");
await click("#topic-footer-button-share-and-invite");
assert.notOk(
queryAll(".share-and-invite.modal .modal-panel.share .topic-share-url")
.val()
.includes("?u=eviltrout"),
"it doesn't add the username param when badges are disabled"
);
});
});

View File

@ -1,12 +1,55 @@
import { click, visit } from "@ember/test-helpers";
import {
acceptance,
exists,
queryAll,
} from "discourse/tests/helpers/qunit-helpers";
import { click, visit } from "@ember/test-helpers";
import selectKit from "discourse/tests/helpers/select-kit-helper";
import { test } from "qunit";
acceptance("Share and Invite modal", function (needs) {
needs.user();
test("Topic footer button", async function (assert) {
await visit("/t/internationalization-localization/280");
assert.ok(
exists("#topic-footer-button-share-and-invite"),
"the button exists"
);
await click("#topic-footer-button-share-and-invite");
assert.ok(exists(".share-topic-modal"), "it shows the modal");
assert.ok(
queryAll("input.invite-link")
.val()
.includes("/t/internationalization-localization/280?u=eviltrout"),
"it shows the topic sharing url"
);
assert.ok(queryAll(".social-link").length > 1, "it shows social sources");
assert.ok(
exists(".btn-primary[aria-label='Notify']"),
"it shows the notify button"
);
assert.ok(
exists(".btn-primary[aria-label='Invite']"),
"it shows the invite button"
);
});
test("Post date link", async function (assert) {
await visit("/t/internationalization-localization/280");
await click("#post_2 .post-info.post-date a");
assert.ok(exists("#share-link"), "it shows the share modal");
});
});
acceptance("Share and Invite modal - mobile", function (needs) {
needs.user();
needs.mobileView();
@ -23,67 +66,19 @@ acceptance("Share and Invite modal - mobile", function (needs) {
await subject.expand();
await subject.selectRowByValue("share-and-invite");
assert.ok(exists(".share-and-invite.modal"), "it shows the modal");
assert.ok(
exists(".share-and-invite.modal .modal-tab.share"),
"it shows the share tab"
);
assert.ok(
exists(".share-and-invite.modal .modal-tab.share.is-active"),
"it activates the share tab by default"
);
assert.ok(
exists(".share-and-invite.modal .modal-tab.invite"),
"it shows the invite tab"
);
assert.equal(
queryAll(".share-and-invite.modal .modal-panel.share .title").text(),
"Topic: Internationalization / localization",
"it shows the topic title"
);
assert.ok(
queryAll(".share-and-invite.modal .modal-panel.share .topic-share-url")
.val()
.includes("/t/internationalization-localization/280?u=eviltrout"),
"it shows the topic sharing url"
);
assert.ok(
queryAll(".share-and-invite.modal .social-link").length > 1,
"it shows social sources"
);
});
test("Post date link", async function (assert) {
await visit("/t/internationalization-localization/280");
await click("#post_2 .post-info.post-date a");
assert.ok(exists("#share-link"), "it shows the share modal");
assert.ok(exists(".share-topic-modal"), "it shows the modal");
});
});
acceptance("Share url with badges disabled - mobile", function (needs) {
acceptance("Share url with badges disabled - desktop", function (needs) {
needs.user();
needs.mobileView();
needs.settings({
enable_badges: false,
});
test("topic footer button - badges disabled - mobile", async function (assert) {
needs.settings({ enable_badges: false });
test("topic footer button - badges disabled - desktop", async function (assert) {
await visit("/t/internationalization-localization/280");
const subject = selectKit(".topic-footer-mobile-dropdown");
await subject.expand();
await subject.selectRowByValue("share-and-invite");
await click("#topic-footer-button-share-and-invite");
assert.notOk(
queryAll(".share-and-invite.modal .modal-panel.share .topic-share-url")
.val()
.includes("?u=eviltrout"),
queryAll("input.invite-link").val().includes("?u=eviltrout"),
"it doesn't add the username param when badges are disabled"
);
});

View File

@ -829,7 +829,8 @@
}
}
.create-invite-modal {
.create-invite-modal,
.share-topic-modal {
.input-group {
margin-bottom: 1em;
@ -842,8 +843,8 @@
}
}
.radio-group {
input[type="radio"] {
.invite-type {
input[type="checkbox"] {
display: inline;
vertical-align: middle;
margin-top: -1px;
@ -855,12 +856,14 @@
}
.group-chooser,
.user-chooser,
.future-date-input-selector {
width: 100%;
}
.input-group input[type="text"],
.input-group .btn,
.user-chooser .select-kit-header,
.future-date-input .select-kit-header {
height: 34px;
}
@ -906,4 +909,37 @@
width: 80px;
}
}
.show-advanced {
margin-left: auto;
margin-right: 0;
}
}
.share-topic-modal {
.sources {
align-items: center;
display: flex;
flex-wrap: wrap;
flex-direction: row;
margin-bottom: 1em;
.social-link {
font-size: $font-up-6;
margin-right: 8px;
}
.btn-primary {
border-radius: 4px;
height: calc(#{$font-up-6} - 4px);
margin-bottom: 2px;
margin-right: 8px;
padding-left: 8px;
padding-right: 8px;
.d-icon {
font-size: $font-up-3;
}
}
}
}

View File

@ -109,7 +109,8 @@
}
.create-invite-modal,
.create-invite-bulk-modal {
.create-invite-bulk-modal,
.share-topic-modal {
.modal-inner-container {
width: 600px;
}

View File

@ -635,6 +635,39 @@ class TopicsController < ApplicationController
end
end
def invite_notify
topic = Topic.find_by(id: params[:topic_id])
guardian.ensure_can_see!(topic)
usernames = params[:usernames]
raise Discourse::InvalidParameters.new(:usernames) if !usernames.kind_of?(Array) || (!current_user.staff? && usernames.size > 1)
users = User.where(username_lower: usernames.map(&:downcase))
raise Discourse::InvalidParameters.new(:usernames) if usernames.size != users.size
topic.rate_limit_topic_invitation(current_user)
users.find_each do |user|
if !user.guardian.can_see_topic?(topic)
return render json: failed_json.merge(error: I18n.t('topic_invite.user_cannot_see_topic', username: user.username)), status: 422
end
end
users.find_each do |user|
last_notification = user.notifications
.where(notification_type: Notification.types[:invited_to_topic])
.where(topic_id: topic.id)
.where(post_number: 1)
.where('created_at > ?', 1.hour.ago)
if !last_notification.exists?
topic.create_invite_notification!(user, Notification.types[:invited_to_topic], current_user.username)
end
end
render json: success_json
end
def invite_group
group = Group.find_by(name: params[:group])
raise Discourse::NotFound unless group

View File

@ -1683,6 +1683,27 @@ class Topic < ActiveRecord::Base
email_addresses.to_a
end
def create_invite_notification!(target_user, notification_type, username)
target_user.notifications.create!(
notification_type: notification_type,
topic_id: self.id,
post_number: 1,
data: {
topic_title: self.title,
display_username: username
}.to_json
)
end
def rate_limit_topic_invitation(invited_by)
RateLimiter.new(
invited_by,
"topic-invitations-per-day",
SiteSetting.max_topic_invitations_per_day,
1.day.to_i
).performed!
end
private
def invite_to_private_message(invited_by, target_user, guardian)
@ -1711,7 +1732,7 @@ class Topic < ActiveRecord::Base
Topic.transaction do
rate_limit_topic_invitation(invited_by)
if group_ids
if group_ids.present?
(
self.category.groups.where(id: group_ids).where(automatic: false) -
target_user.groups.where(automatic: false)
@ -1743,29 +1764,6 @@ class Topic < ActiveRecord::Base
def apply_per_day_rate_limit_for(key, method_name)
RateLimiter.new(user, "#{key}-per-day", SiteSetting.get(method_name), 1.day.to_i)
end
def create_invite_notification!(target_user, notification_type, username)
target_user.notifications.create!(
notification_type: notification_type,
topic_id: self.id,
post_number: 1,
data: {
topic_title: self.title,
display_username: username
}.to_json
)
end
def rate_limit_topic_invitation(invited_by)
RateLimiter.new(
invited_by,
"topic-invitations-per-day",
SiteSetting.max_topic_invitations_per_day,
1.day.to_i
).performed!
true
end
end
# == Schema Information

View File

@ -1505,10 +1505,8 @@ en:
show_advanced: "Show Advanced Options"
hide_advanced: "Hide Advanced Options"
type_link: "Invite one or more people with a link"
type_email: "Invite just one email address"
restrict_email: "Restrict the invite to one email address"
email: "Limit to email address:"
max_redemptions_allowed: "Max number of uses:"
add_to_groups: "Add to groups:"
@ -2664,6 +2662,15 @@ en:
title: "Share"
extended_title: "Share a link"
help: "share a link to this topic"
instructions: "Share a link to this topic:"
copied: "Topic link copied."
notify_users:
title: "Notify"
instructions: "Notify the following users about this topic:"
success:
one: "Successfully notified %{username} about this topic."
other: "Successfully notified all users about this topic."
invite_users: "Invite"
print:
title: "Print"

View File

@ -264,6 +264,7 @@ en:
muted_topic: "Sorry, that user muted this topic."
receiver_does_not_allow_pm: "Sorry, that user does not allow you to send them private messages."
sender_does_not_allow_pm: "Sorry, you do not allow that user to send you private messages."
user_cannot_see_topic: "%{username} cannot see the topic."
backup:
operation_already_running: "An operation is currently running. Can't start a new job right now."

View File

@ -808,6 +808,7 @@ Discourse::Application.routes.draw do
post "t/:topic_id/timings" => "topics#timings", constraints: { topic_id: /\d+/ }
post "t/:topic_id/invite" => "topics#invite", constraints: { topic_id: /\d+/ }
post "t/:topic_id/invite-group" => "topics#invite_group", constraints: { topic_id: /\d+/ }
post "t/:topic_id/invite-notify" => "topics#invite_notify", constraints: { topic_id: /\d+/ }
post "t/:topic_id/move-posts" => "topics#move_posts", constraints: { topic_id: /\d+/ }
post "t/:topic_id/merge-topic" => "topics#merge_topic", constraints: { topic_id: /\d+/ }
post "t/:topic_id/change-owner" => "topics#change_post_owners", constraints: { topic_id: /\d+/ }

View File

@ -2542,6 +2542,44 @@ RSpec.describe TopicsController do
end
end
describe '#invite_notify' do
let(:user2) { Fabricate(:user) }
it 'does not notify same user multiple times' do
sign_in(user)
expect { post "/t/#{topic.id}/invite-notify.json", params: { usernames: [user2.username] } }
.to change { Notification.count }.by(1)
expect(response.status).to eq(200)
expect { post "/t/#{topic.id}/invite-notify.json", params: { usernames: [user2.username] } }
.to change { Notification.count }.by(0)
expect(response.status).to eq(200)
freeze_time 1.day.from_now
expect { post "/t/#{topic.id}/invite-notify.json", params: { usernames: [user2.username] } }
.to change { Notification.count }.by(1)
expect(response.status).to eq(200)
end
it 'does not let regular users to notify multiple users' do
sign_in(user)
expect { post "/t/#{topic.id}/invite-notify.json", params: { usernames: [admin.username, user2.username] } }
.to change { Notification.count }.by(0)
expect(response.status).to eq(400)
end
it 'lets staff to notify multiple users' do
sign_in(admin)
expect { post "/t/#{topic.id}/invite-notify.json", params: { usernames: [user.username, user2.username] } }
.to change { Notification.count }.by(2)
expect(response.status).to eq(200)
end
end
describe '#invite_group' do
let!(:admins) { Group[:admins] }