Skip to content

Commit

Permalink
FEATURE: multiple use invite links (#9813)
Browse files Browse the repository at this point in the history
  • Loading branch information
arpitjalan committed Jun 9, 2020
1 parent 6b7a2d6 commit 3094459
Show file tree
Hide file tree
Showing 48 changed files with 1,273 additions and 344 deletions.
@@ -0,0 +1,98 @@
import I18n from "I18n";
import Component from "@ember/component";
import Group from "discourse/models/group";
import { readOnly } from "@ember/object/computed";
import { action } from "@ember/object";
import discourseComputed from "discourse-common/utils/decorators";
import Invite from "discourse/models/invite";

export default Component.extend({
inviteModel: readOnly("panel.model.inviteModel"),
userInvitedShow: readOnly("panel.model.userInvitedShow"),
isStaff: readOnly("currentUser.staff"),
maxRedemptionAllowed: 5,
inviteExpiresAt: moment()
.add(1, "month")
.format("YYYY-MM-DD"),

willDestroyElement() {
this._super(...arguments);

this.reset();
},

@discourseComputed("isStaff", "inviteModel.saving", "maxRedemptionAllowed")
disabled(isStaff, saving, canInviteTo, maxRedemptionAllowed) {
if (saving) return true;
if (!isStaff) return true;
if (maxRedemptionAllowed < 2) return true;

return false;
},

groupFinder(term) {
return Group.findAll({ term, ignore_automatic: true });
},

errorMessage: I18n.t("user.invited.invite_link.error"),

reset() {
this.set("maxRedemptionAllowed", 5);

this.inviteModel.setProperties({
groupNames: null,
error: false,
saving: false,
finished: false,
inviteLink: null
});
},

@action
generateMultipleUseInviteLink() {
if (this.disabled) {
return;
}

const groupNames = this.get("inviteModel.groupNames");
const maxRedemptionAllowed = this.maxRedemptionAllowed;
const inviteExpiresAt = this.inviteExpiresAt;
const userInvitedController = this.userInvitedShow;
const model = this.inviteModel;
model.setProperties({ saving: true, error: false });

return model
.generateMultipleUseInviteLink(
groupNames,
maxRedemptionAllowed,
inviteExpiresAt
)
.then(result => {
model.setProperties({
saving: false,
finished: true,
inviteLink: result
});

if (userInvitedController) {
Invite.findInvitedBy(
this.currentUser,
userInvitedController.filter
).then(inviteModel => {
userInvitedController.setProperties({
model: inviteModel,
totalInvites: inviteModel.invites.length
});
});
}
})
.catch(e => {
if (e.jqXHR.responseJSON && e.jqXHR.responseJSON.errors) {
this.set("errorMessage", e.jqXHR.responseJSON.errors[0]);
} else {
this.set("errorMessage", I18n.t("user.invited.invite_link.error"));
}
model.setProperties({ saving: false, error: true });
});
}
});
74 changes: 57 additions & 17 deletions app/assets/javascripts/discourse/app/controllers/invites-show.js
@@ -1,31 +1,48 @@
import I18n from "I18n";
import { isEmpty } from "@ember/utils";
import { alias, notEmpty } from "@ember/object/computed";
import { alias, notEmpty, or, readOnly } from "@ember/object/computed";
import Controller from "@ember/controller";
import discourseComputed from "discourse-common/utils/decorators";
import getUrl from "discourse-common/lib/get-url";
import DiscourseURL from "discourse/lib/url";
import { ajax } from "discourse/lib/ajax";
import { emailValid } from "discourse/lib/utilities";
import PasswordValidation from "discourse/mixins/password-validation";
import UsernameValidation from "discourse/mixins/username-validation";
import NameValidation from "discourse/mixins/name-validation";
import UserFieldsValidation from "discourse/mixins/user-fields-validation";
import { findAll as findLoginMethods } from "discourse/models/login-method";
import EmberObject from "@ember/object";

export default Controller.extend(
PasswordValidation,
UsernameValidation,
NameValidation,
UserFieldsValidation,
{
invitedBy: alias("model.invited_by"),
invitedBy: readOnly("model.invited_by"),
email: alias("model.email"),
accountUsername: alias("model.username"),
passwordRequired: notEmpty("accountPassword"),
successMessage: null,
errorMessage: null,
userFields: null,
inviteImageUrl: getUrl("/images/envelope.svg"),
isInviteLink: readOnly("model.is_invite_link"),
submitDisabled: or(
"emailValidation.failed",
"usernameValidation.failed",
"passwordValidation.failed",
"nameValidation.failed",
"userFieldsValidation.failed"
),
rejectedEmails: null,

init() {
this._super(...arguments);

this.rejectedEmails = [];
},

@discourseComputed
welcomeTitle() {
Expand All @@ -44,28 +61,42 @@ export default Controller.extend(
return findLoginMethods().length > 0;
},

@discourseComputed(
"usernameValidation.failed",
"passwordValidation.failed",
"nameValidation.failed",
"userFieldsValidation.failed"
)
submitDisabled(
usernameFailed,
passwordFailed,
nameFailed,
userFieldsFailed
) {
return usernameFailed || passwordFailed || nameFailed || userFieldsFailed;
},

@discourseComputed
fullnameRequired() {
return (
this.siteSettings.full_name_required || this.siteSettings.enable_names
);
},

@discourseComputed("email", "rejectedEmails.[]")
emailValidation(email, rejectedEmails) {
// If blank, fail without a reason
if (isEmpty(email)) {
return EmberObject.create({
failed: true
});
}

if (rejectedEmails.includes(email)) {
return EmberObject.create({
failed: true,
reason: I18n.t("user.email.invalid")
});
}

if (emailValid(email)) {
return EmberObject.create({
ok: true,
reason: I18n.t("user.email.ok")
});
}

return EmberObject.create({
failed: true,
reason: I18n.t("user.email.invalid")
});
},

actions: {
submit() {
const userFields = this.userFields;
Expand All @@ -80,6 +111,7 @@ export default Controller.extend(
url: `/invites/show/${this.get("model.token")}.json`,
type: "PUT",
data: {
email: this.email,
username: this.accountUsername,
name: this.accountName,
password: this.accountPassword,
Expand All @@ -97,6 +129,14 @@ export default Controller.extend(
DiscourseURL.redirectTo(result.redirect_to);
}
} else {
if (
result.errors &&
result.errors.email &&
result.errors.email.length > 0 &&
result.values
) {
this.rejectedEmails.pushObject(result.values.email);
}
if (
result.errors &&
result.errors.password &&
Expand Down
@@ -1,5 +1,5 @@
import I18n from "I18n";
import { equal, reads, gte } from "@ember/object/computed";
import { equal, reads } from "@ember/object/computed";
import Controller from "@ember/controller";
import Invite from "discourse/models/invite";
import discourseDebounce from "discourse/lib/debounce";
Expand Down Expand Up @@ -35,21 +35,30 @@ export default Controller.extend({
}, INPUT_DELAY),

inviteRedeemed: equal("filter", "redeemed"),
invitePending: equal("filter", "pending"),

@discourseComputed("filter")
inviteLinks(filter) {
return filter === "links" && this.currentUser.staff;
},

@discourseComputed("filter")
showBulkActionButtons(filter) {
return (
filter === "pending" &&
this.model.invites.length > 4 &&
this.currentUser.get("staff")
this.currentUser.staff
);
},

canInviteToForum: reads("currentUser.can_invite_to_forum"),

canBulkInvite: reads("currentUser.admin"),
canSendInviteLink: reads("currentUser.staff"),

showSearch: gte("totalInvites", 10),
@discourseComputed("totalInvites", "inviteLinks")
showSearch(totalInvites, inviteLinks) {
return totalInvites >= 10 && !inviteLinks;
},

@discourseComputed("invitesCount.total", "invitesCount.pending")
pendingLabel(invitesCountTotal, invitesCountPending) {
Expand All @@ -73,6 +82,17 @@ export default Controller.extend({
}
},

@discourseComputed("invitesCount.total", "invitesCount.links")
linksLabel(invitesCountTotal, invitesCountLinks) {
if (invitesCountTotal > 50) {
return I18n.t("user.invited.links_tab_with_count", {
count: invitesCountLinks
});
} else {
return I18n.t("user.invited.links_tab");
}
},

actions: {
rescind(invite) {
invite.rescind();
Expand Down
11 changes: 9 additions & 2 deletions app/assets/javascripts/discourse/app/models/invite.js
Expand Up @@ -10,7 +10,7 @@ const Invite = EmberObject.extend({
rescind() {
ajax("/invites", {
type: "DELETE",
data: { email: this.email }
data: { id: this.id }
});
this.set("rescinded", true);
},
Expand Down Expand Up @@ -42,7 +42,14 @@ Invite.reopenClass({
if (!isNone(search)) data.search = search;
data.offset = offset || 0;

return ajax(userPath(`${user.username_lower}/invited.json`), {
let path;
if (filter === "links") {
path = userPath(`${user.username_lower}/invite_links.json`);
} else {
path = userPath(`${user.username_lower}/invited.json`);
}

return ajax(path, {
data
}).then(result => {
result.invites = result.invites.map(i => Invite.create(i));
Expand Down
11 changes: 11 additions & 0 deletions app/assets/javascripts/discourse/app/models/user.js
Expand Up @@ -654,6 +654,17 @@ const User = RestModel.extend({
});
},

generateMultipleUseInviteLink(
group_names,
max_redemptions_allowed,
expires_at
) {
return ajax("/invites/link", {
type: "POST",
data: { group_names, max_redemptions_allowed, expires_at }
});
},

@observes("muted_category_ids")
updateMutedCategories() {
this.set("mutedCategories", Category.findByIds(this.muted_category_ids));
Expand Down
51 changes: 42 additions & 9 deletions app/assets/javascripts/discourse/app/routes/user-invited-show.js
Expand Up @@ -30,18 +30,51 @@ export default DiscourseRoute.extend({

actions: {
showInvite() {
const panels = [
{
id: "invite",
title: "user.invited.single_user",
model: {
inviteModel: this.currentUser,
userInvitedShow: this.controllerFor("user-invited-show")
}
}
];

if (this.get("currentUser.staff")) {
panels.push({
id: "invite-link",
title: "user.invited.multiple_user",
model: {
inviteModel: this.currentUser,
userInvitedShow: this.controllerFor("user-invited-show")
}
});
}

showModal("share-and-invite", {
modalClass: "share-and-invite",
panels: [
{
id: "invite",
title: "user.invited.create",
model: {
inviteModel: this.currentUser,
userInvitedShow: this.controllerFor("user-invited-show")
}
panels
});
},

editInvite(inviteKey) {
const inviteLink = `${Discourse.BaseUrl}/invites/${inviteKey}`;
this.currentUser.setProperties({ finished: true, inviteLink });
const panels = [
{
id: "invite-link",
title: "user.invited.generate_link",
model: {
inviteModel: this.currentUser,
userInvitedShow: this.controllerFor("user-invited-show")
}
]
}
];

showModal("share-and-invite", {
modalClass: "share-and-invite",
panels
});
}
}
Expand Down

2 comments on commit 3094459

@arpitjalan
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@discoursebot
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This commit has been mentioned on Discourse Meta. There might be relevant details there:

https://meta.discourse.org/t/cosmetic-problem-with-invites-screen-in-brave/153613/5

Please sign in to comment.