mirror of
https://github.com/discourse/discourse.git
synced 2025-01-18 11:32:46 +08:00
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:
parent
e3b1d1a718
commit
cfee2728ce
|
@ -30,6 +30,8 @@ export default Controller.extend(
|
||||||
});
|
});
|
||||||
|
|
||||||
this.setProperties({
|
this.setProperties({
|
||||||
|
invite: null,
|
||||||
|
invites: null,
|
||||||
autogenerated: false,
|
autogenerated: false,
|
||||||
showAdvanced: false,
|
showAdvanced: false,
|
||||||
});
|
});
|
||||||
|
@ -41,7 +43,7 @@ export default Controller.extend(
|
||||||
if (this.autogenerated) {
|
if (this.autogenerated) {
|
||||||
this.invite
|
this.invite
|
||||||
.destroy()
|
.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) {
|
setAutogenerated(value) {
|
||||||
if ((this.autogenerated || !this.invite.id) && !value) {
|
if (this.invites && (this.autogenerated || !this.invite.id) && !value) {
|
||||||
this.invites.unshiftObject(this.invite);
|
this.invites.unshiftObject(this.invite);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -168,6 +170,15 @@ export default Controller.extend(
|
||||||
this.save({ sendEmail: false, copy: true });
|
this.save({ sendEmail: false, copy: true });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@action
|
||||||
|
toggleLimitToEmail() {
|
||||||
|
const limitToEmail = !this.limitToEmail;
|
||||||
|
this.setProperties({
|
||||||
|
limitToEmail,
|
||||||
|
type: limitToEmail ? "email" : "link",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
@action
|
@action
|
||||||
saveInvite(sendEmail) {
|
saveInvite(sendEmail) {
|
||||||
this.appEvents.trigger("modal-body:clearFlash");
|
this.appEvents.trigger("modal-body:clearFlash");
|
||||||
|
@ -181,5 +192,10 @@ export default Controller.extend(
|
||||||
this.set("buffered.email", result[0].email[0]);
|
this.set("buffered.email", result[0].email[0]);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@action
|
||||||
|
toggleAdvanced() {
|
||||||
|
this.toggleProperty("showAdvanced");
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
116
app/assets/javascripts/discourse/app/controllers/share-topic.js
Normal file
116
app/assets/javascripts/discourse/app/controllers/share-topic.js
Normal 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 });
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
|
@ -25,39 +25,10 @@ export default {
|
||||||
},
|
},
|
||||||
title: "topic.share.help",
|
title: "topic.share.help",
|
||||||
action() {
|
action() {
|
||||||
const panels = [
|
const controller = showModal("share-topic");
|
||||||
{
|
controller.setProperties({
|
||||||
id: "share",
|
allowInvites: this.canInviteTo && !this.inviteDisabled,
|
||||||
title: "topic.share.extended_title",
|
topic: this.topic,
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
dropdown() {
|
dropdown() {
|
||||||
|
|
|
@ -16,15 +16,25 @@
|
||||||
<p>{{expiresAtLabel}}</p>
|
<p>{{expiresAtLabel}}</p>
|
||||||
|
|
||||||
<div class="input-group invite-type">
|
<div class="input-group invite-type">
|
||||||
<div class="radio-group">
|
{{input type="checkbox" id="invite-type" checked=limitToEmail click=(action "toggleLimitToEmail")}}
|
||||||
{{radio-button id="invite-type-link" name="invite-type" value="link" selection=type}}
|
<label for="invite-type">{{i18n "user.invited.invite.restrict_email"}}</label>
|
||||||
<label for="invite-type-link">{{i18n "user.invited.invite.type_link"}}</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="radio-group">
|
{{#if isEmail}}
|
||||||
{{radio-button id="invite-type-email" name="invite-type" value="email" selection=type}}
|
<div class="invite-input-with-button">
|
||||||
<label for="invite-type-email">{{i18n "user.invited.invite.type_email"}}</label>
|
{{input
|
||||||
</div>
|
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>
|
</div>
|
||||||
|
|
||||||
{{#if isLink}}
|
{{#if isLink}}
|
||||||
|
@ -41,34 +51,28 @@
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
{{#if isEmail}}
|
{{#if isEmail}}
|
||||||
<div class="input-group invite-email">
|
{{#if showAdvanced}}
|
||||||
<label for="invite-email">{{i18n "user.invited.invite.email"}}</label>
|
<div class="input-group invite-custom-message">
|
||||||
<div class="invite-input-with-button">
|
<label for="invite-message">{{i18n "user.invited.invite.custom_message"}}</label>
|
||||||
{{input
|
{{textarea id="invite-message" value=buffered.custom_message}}
|
||||||
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>
|
</div>
|
||||||
</div>
|
{{/if}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
{{#if currentUser.staff}}
|
{{#if currentUser.staff}}
|
||||||
<p id="invite-show-advanced">
|
{{#if showAdvanced}}
|
||||||
{{#if showAdvanced}}
|
<div class="input-group invite-to-topic">
|
||||||
<a href {{action (mut showAdvanced) false}}>{{d-icon "caret-down"}} {{i18n "user.invited.invite.hide_advanced"}}</a>
|
{{choose-topic
|
||||||
{{else}}
|
selectedTopicId=buffered.topicId
|
||||||
<a href {{action (mut showAdvanced) true}}>{{d-icon "caret-right"}} {{i18n "user.invited.invite.show_advanced"}}</a>
|
topicTitle=buffered.topicTitle
|
||||||
{{/if}}
|
additionalFilters="status:public"
|
||||||
</p>
|
label="user.invited.invite.invite_to_topic"
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if currentUser.staff}}
|
||||||
{{#if showAdvanced}}
|
{{#if showAdvanced}}
|
||||||
<div class="input-group invite-to-groups">
|
<div class="input-group invite-to-groups">
|
||||||
<label>{{i18n "user.invited.invite.add_to_groups"}}</label>
|
<label>{{i18n "user.invited.invite.add_to_groups"}}</label>
|
||||||
|
@ -79,16 +83,11 @@
|
||||||
onChange=(action (mut buffered.groupIds))
|
onChange=(action (mut buffered.groupIds))
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
<div class="input-group invite-to-topic">
|
{{#if currentUser.staff}}
|
||||||
{{choose-topic
|
{{#if showAdvanced}}
|
||||||
selectedTopicId=buffered.topicId
|
|
||||||
topicTitle=buffered.topicTitle
|
|
||||||
additionalFilters="status:public"
|
|
||||||
label="user.invited.invite.invite_to_topic"
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="input-group invite-expires-at">
|
<div class="input-group invite-expires-at">
|
||||||
{{future-date-input
|
{{future-date-input
|
||||||
displayLabel=(i18n "user.invited.invite.expires_at")
|
displayLabel=(i18n "user.invited.invite.expires_at")
|
||||||
|
@ -98,13 +97,6 @@
|
||||||
onChangeInput=(action (mut buffered.expires_at))
|
onChangeInput=(action (mut buffered.expires_at))
|
||||||
}}
|
}}
|
||||||
</div>
|
</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}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</form>
|
</form>
|
||||||
|
@ -130,8 +122,9 @@
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
{{d-button
|
{{d-button
|
||||||
label="cancel"
|
action=(action "toggleAdvanced")
|
||||||
class="btn-flat"
|
class="show-advanced"
|
||||||
action=(route-action "closeModal")
|
icon="cog"
|
||||||
|
title=(if showAdvanced "user.invited.invite.hide_advanced" "user.invited.invite.show_advanced")
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -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}}
|
|
@ -43,7 +43,7 @@ acceptance("Invites - Create & Edit Invite Modal", function (needs) {
|
||||||
"shows an invite link when modal is opened"
|
"shows an invite link when modal is opened"
|
||||||
);
|
);
|
||||||
|
|
||||||
await click("#invite-show-advanced a");
|
await click(".modal-footer .show-advanced");
|
||||||
await assert.ok(
|
await assert.ok(
|
||||||
find(".invite-to-groups").length > 0,
|
find(".invite-to-groups").length > 0,
|
||||||
"shows advanced options"
|
"shows advanced options"
|
||||||
|
@ -57,7 +57,7 @@ acceptance("Invites - Create & Edit Invite Modal", function (needs) {
|
||||||
"shows advanced options"
|
"shows advanced options"
|
||||||
);
|
);
|
||||||
|
|
||||||
await click(".modal-footer .btn:last-child");
|
await click(".modal-close");
|
||||||
assert.ok(deleted, "deletes the invite if not saved");
|
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"
|
"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");
|
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(".invite-link .btn");
|
||||||
|
|
||||||
await click(".modal-footer .btn:last-child");
|
await click(".modal-close");
|
||||||
assert.notOk(deleted, "does not delete invite on 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 visit("/u/eviltrout/invited/pending");
|
||||||
await click(".invite-controls .btn:first-child");
|
await click(".invite-controls .btn:first-child");
|
||||||
|
|
||||||
await click("#invite-type-email");
|
await click("#invite-type");
|
||||||
await click(".invite-link .btn");
|
await click(".invite-link .btn");
|
||||||
assert.equal(
|
assert.equal(
|
||||||
find("#modal-alert").text(),
|
find("#modal-alert").text(),
|
||||||
|
@ -130,7 +130,6 @@ acceptance("Invites - Link Invites", function (needs) {
|
||||||
await visit("/u/eviltrout/invited/pending");
|
await visit("/u/eviltrout/invited/pending");
|
||||||
await click(".invite-controls .btn:first-child");
|
await click(".invite-controls .btn:first-child");
|
||||||
|
|
||||||
await click("#invite-type-link");
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
find("#invite-max-redemptions").length,
|
find("#invite-max-redemptions").length,
|
||||||
"shows max redemptions field"
|
"shows max redemptions field"
|
||||||
|
@ -173,7 +172,7 @@ acceptance("Invites - Email Invites", function (needs) {
|
||||||
await visit("/u/eviltrout/invited/pending");
|
await visit("/u/eviltrout/invited/pending");
|
||||||
await click(".invite-controls .btn:first-child");
|
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");
|
assert.ok(find("#invite-email").length, "shows email field");
|
||||||
|
|
||||||
|
|
|
@ -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"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,12 +1,55 @@
|
||||||
|
import { click, visit } from "@ember/test-helpers";
|
||||||
import {
|
import {
|
||||||
acceptance,
|
acceptance,
|
||||||
exists,
|
exists,
|
||||||
queryAll,
|
queryAll,
|
||||||
} from "discourse/tests/helpers/qunit-helpers";
|
} from "discourse/tests/helpers/qunit-helpers";
|
||||||
import { click, visit } from "@ember/test-helpers";
|
|
||||||
import selectKit from "discourse/tests/helpers/select-kit-helper";
|
import selectKit from "discourse/tests/helpers/select-kit-helper";
|
||||||
import { test } from "qunit";
|
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) {
|
acceptance("Share and Invite modal - mobile", function (needs) {
|
||||||
needs.user();
|
needs.user();
|
||||||
needs.mobileView();
|
needs.mobileView();
|
||||||
|
@ -23,67 +66,19 @@ acceptance("Share and Invite modal - mobile", function (needs) {
|
||||||
await subject.expand();
|
await subject.expand();
|
||||||
await subject.selectRowByValue("share-and-invite");
|
await subject.selectRowByValue("share-and-invite");
|
||||||
|
|
||||||
assert.ok(exists(".share-and-invite.modal"), "it shows the modal");
|
assert.ok(exists(".share-topic-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");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
acceptance("Share url with badges disabled - mobile", function (needs) {
|
acceptance("Share url with badges disabled - desktop", function (needs) {
|
||||||
needs.user();
|
needs.user();
|
||||||
needs.mobileView();
|
needs.settings({ enable_badges: false });
|
||||||
needs.settings({
|
test("topic footer button - badges disabled - desktop", async function (assert) {
|
||||||
enable_badges: false,
|
|
||||||
});
|
|
||||||
test("topic footer button - badges disabled - mobile", async function (assert) {
|
|
||||||
await visit("/t/internationalization-localization/280");
|
await visit("/t/internationalization-localization/280");
|
||||||
|
await click("#topic-footer-button-share-and-invite");
|
||||||
const subject = selectKit(".topic-footer-mobile-dropdown");
|
|
||||||
await subject.expand();
|
|
||||||
await subject.selectRowByValue("share-and-invite");
|
|
||||||
|
|
||||||
assert.notOk(
|
assert.notOk(
|
||||||
queryAll(".share-and-invite.modal .modal-panel.share .topic-share-url")
|
queryAll("input.invite-link").val().includes("?u=eviltrout"),
|
||||||
.val()
|
|
||||||
.includes("?u=eviltrout"),
|
|
||||||
"it doesn't add the username param when badges are disabled"
|
"it doesn't add the username param when badges are disabled"
|
||||||
);
|
);
|
||||||
});
|
});
|
|
@ -829,7 +829,8 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-invite-modal {
|
.create-invite-modal,
|
||||||
|
.share-topic-modal {
|
||||||
.input-group {
|
.input-group {
|
||||||
margin-bottom: 1em;
|
margin-bottom: 1em;
|
||||||
|
|
||||||
|
@ -842,8 +843,8 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.radio-group {
|
.invite-type {
|
||||||
input[type="radio"] {
|
input[type="checkbox"] {
|
||||||
display: inline;
|
display: inline;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
margin-top: -1px;
|
margin-top: -1px;
|
||||||
|
@ -855,12 +856,14 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-chooser,
|
.group-chooser,
|
||||||
|
.user-chooser,
|
||||||
.future-date-input-selector {
|
.future-date-input-selector {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-group input[type="text"],
|
.input-group input[type="text"],
|
||||||
.input-group .btn,
|
.input-group .btn,
|
||||||
|
.user-chooser .select-kit-header,
|
||||||
.future-date-input .select-kit-header {
|
.future-date-input .select-kit-header {
|
||||||
height: 34px;
|
height: 34px;
|
||||||
}
|
}
|
||||||
|
@ -906,4 +909,37 @@
|
||||||
width: 80px;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -109,7 +109,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-invite-modal,
|
.create-invite-modal,
|
||||||
.create-invite-bulk-modal {
|
.create-invite-bulk-modal,
|
||||||
|
.share-topic-modal {
|
||||||
.modal-inner-container {
|
.modal-inner-container {
|
||||||
width: 600px;
|
width: 600px;
|
||||||
}
|
}
|
||||||
|
|
|
@ -635,6 +635,39 @@ class TopicsController < ApplicationController
|
||||||
end
|
end
|
||||||
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
|
def invite_group
|
||||||
group = Group.find_by(name: params[:group])
|
group = Group.find_by(name: params[:group])
|
||||||
raise Discourse::NotFound unless group
|
raise Discourse::NotFound unless group
|
||||||
|
|
|
@ -1683,6 +1683,27 @@ class Topic < ActiveRecord::Base
|
||||||
email_addresses.to_a
|
email_addresses.to_a
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
def invite_to_private_message(invited_by, target_user, guardian)
|
def invite_to_private_message(invited_by, target_user, guardian)
|
||||||
|
@ -1711,7 +1732,7 @@ class Topic < ActiveRecord::Base
|
||||||
Topic.transaction do
|
Topic.transaction do
|
||||||
rate_limit_topic_invitation(invited_by)
|
rate_limit_topic_invitation(invited_by)
|
||||||
|
|
||||||
if group_ids
|
if group_ids.present?
|
||||||
(
|
(
|
||||||
self.category.groups.where(id: group_ids).where(automatic: false) -
|
self.category.groups.where(id: group_ids).where(automatic: false) -
|
||||||
target_user.groups.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)
|
def apply_per_day_rate_limit_for(key, method_name)
|
||||||
RateLimiter.new(user, "#{key}-per-day", SiteSetting.get(method_name), 1.day.to_i)
|
RateLimiter.new(user, "#{key}-per-day", SiteSetting.get(method_name), 1.day.to_i)
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
# == Schema Information
|
# == Schema Information
|
||||||
|
|
|
@ -1505,10 +1505,8 @@ en:
|
||||||
show_advanced: "Show Advanced Options"
|
show_advanced: "Show Advanced Options"
|
||||||
hide_advanced: "Hide Advanced Options"
|
hide_advanced: "Hide Advanced Options"
|
||||||
|
|
||||||
type_link: "Invite one or more people with a link"
|
restrict_email: "Restrict the invite to one email address"
|
||||||
type_email: "Invite just one email address"
|
|
||||||
|
|
||||||
email: "Limit to email address:"
|
|
||||||
max_redemptions_allowed: "Max number of uses:"
|
max_redemptions_allowed: "Max number of uses:"
|
||||||
|
|
||||||
add_to_groups: "Add to groups:"
|
add_to_groups: "Add to groups:"
|
||||||
|
@ -2664,6 +2662,15 @@ en:
|
||||||
title: "Share"
|
title: "Share"
|
||||||
extended_title: "Share a link"
|
extended_title: "Share a link"
|
||||||
help: "share a link to this topic"
|
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:
|
print:
|
||||||
title: "Print"
|
title: "Print"
|
||||||
|
|
|
@ -264,6 +264,7 @@ en:
|
||||||
muted_topic: "Sorry, that user muted this topic."
|
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."
|
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."
|
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:
|
backup:
|
||||||
operation_already_running: "An operation is currently running. Can't start a new job right now."
|
operation_already_running: "An operation is currently running. Can't start a new job right now."
|
||||||
|
|
|
@ -808,6 +808,7 @@ Discourse::Application.routes.draw do
|
||||||
post "t/:topic_id/timings" => "topics#timings", constraints: { topic_id: /\d+/ }
|
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" => "topics#invite", constraints: { topic_id: /\d+/ }
|
||||||
post "t/:topic_id/invite-group" => "topics#invite_group", 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/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/merge-topic" => "topics#merge_topic", constraints: { topic_id: /\d+/ }
|
||||||
post "t/:topic_id/change-owner" => "topics#change_post_owners", constraints: { topic_id: /\d+/ }
|
post "t/:topic_id/change-owner" => "topics#change_post_owners", constraints: { topic_id: /\d+/ }
|
||||||
|
|
|
@ -2542,6 +2542,44 @@ RSpec.describe TopicsController do
|
||||||
end
|
end
|
||||||
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
|
describe '#invite_group' do
|
||||||
let!(:admins) { Group[:admins] }
|
let!(:admins) { Group[:admins] }
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue
Block a user