Improved create invite modal (#14151)

* FEATURE: Always show advanced invite options

The UI is more simple and more efficient than how it was when the
advanced options toggle was introduced. It does not make sense to keep
it anymore.

* UX: Minor copy edits

* UX: Merge expire invite controls

There were two controls in the create invite modal. One was a static
text that displayed how much time is left until the invite expires. The
other one was a datetime selector that set the time the invite expires.

This commit merges the two controls in a single one: staff users will
continue to see the datetime selector without the static text and
regular users will only see the static text because they cannot set
when the invite expires.

* UX: Remove invite link

It should only be visible after the invite was created.
This commit is contained in:
Dan Ungureanu 2021-11-18 20:19:02 +02:00 committed by GitHub
parent ed2c3ebd71
commit 6ae065f9cd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 213 additions and 193 deletions

View File

@ -1,8 +1,10 @@
import { and, empty, equal } from "@ember/object/computed";
import { action } from "@ember/object";
import Component from "@ember/component"; import Component from "@ember/component";
import { FORMAT } from "select-kit/components/future-date-input-selector"; import { action } from "@ember/object";
import { and, empty, equal } from "@ember/object/computed";
import { CLOSE_STATUS_TYPE } from "discourse/controllers/edit-topic-timer";
import buildTimeframes from "discourse/lib/timeframes-builder";
import I18n from "I18n"; import I18n from "I18n";
import { FORMAT } from "select-kit/components/future-date-input-selector";
export default Component.extend({ export default Component.extend({
selection: null, selection: null,
@ -20,12 +22,17 @@ export default Component.extend({
this._super(...arguments); this._super(...arguments);
if (this.input) { if (this.input) {
const datetime = moment(this.input); const dateTime = moment(this.input);
this.setProperties({ const closestTimeframe = this.findClosestTimeframe(dateTime);
selection: "pick_date_and_time", if (closestTimeframe) {
_date: datetime.format("YYYY-MM-DD"), this.set("selection", closestTimeframe.id);
_time: datetime.format("HH:mm"), } else {
}); this.setProperties({
selection: "pick_date_and_time",
_date: dateTime.format("YYYY-MM-DD"),
_time: dateTime.format("HH:mm"),
});
}
} }
}, },
@ -64,4 +71,31 @@ export default Component.extend({
this.attrs.onChangeInput && this.attrs.onChangeInput(null); this.attrs.onChangeInput && this.attrs.onChangeInput(null);
} }
}, },
findClosestTimeframe(dateTime) {
const now = moment();
const futureDateInputSelectorOptions = {
now,
day: now.day(),
includeWeekend: this.includeWeekend,
includeMidFuture: this.includeMidFuture || true,
includeFarFuture: this.includeFarFuture,
includeDateTime: this.includeDateTime,
canScheduleNow: this.includeNow || false,
canScheduleToday: 24 - now.hour() > 6,
};
return buildTimeframes(futureDateInputSelectorOptions).find((tf) => {
const tfDateTime = tf.when(
moment(),
this.statusType !== CLOSE_STATUS_TYPE ? 8 : 18
);
if (tfDateTime) {
const diff = tfDateTime.diff(dateTime);
return 0 <= diff && diff < 60 * 1000;
}
});
},
}); });

View File

@ -9,6 +9,7 @@ import ModalFunctionality from "discourse/mixins/modal-functionality";
import Group from "discourse/models/group"; import Group from "discourse/models/group";
import Invite from "discourse/models/invite"; import Invite from "discourse/models/invite";
import I18n from "I18n"; import I18n from "I18n";
import { FORMAT } from "select-kit/components/future-date-input-selector";
export default Controller.extend( export default Controller.extend(
ModalFunctionality, ModalFunctionality,
@ -16,13 +17,16 @@ export default Controller.extend(
{ {
allGroups: null, allGroups: null,
flashText: null,
flashClass: null,
flashLink: false,
invite: null, invite: null,
invites: null, invites: null,
showAdvanced: false, editing: false,
inviteToTopic: false, inviteToTopic: false,
limitToEmail: false, limitToEmail: false,
autogenerated: false,
isLink: empty("buffered.email"), isLink: empty("buffered.email"),
isEmail: notEmpty("buffered.email"), isEmail: notEmpty("buffered.email"),
@ -33,37 +37,33 @@ export default Controller.extend(
}); });
this.setProperties({ this.setProperties({
flashText: null,
flashClass: null,
flashLink: false,
invite: null, invite: null,
invites: null, invites: null,
showAdvanced: false, editing: false,
inviteToTopic: false, inviteToTopic: false,
limitToEmail: false, limitToEmail: false,
autogenerated: false,
}); });
this.setInvite(Invite.create()); this.setInvite(Invite.create());
this.buffered.setProperties({
max_redemptions_allowed: 1,
expires_at: moment()
.add(this.siteSettings.invite_expiry_days, "days")
.format(FORMAT),
});
}, },
onClose() { onClose() {
if (this.autogenerated) { this.appEvents.trigger("modal-body:clearFlash");
this.invite
.destroy()
.then(() => this.invites && this.invites.removeObject(this.invite));
}
}, },
setInvite(invite) { setInvite(invite) {
this.set("invite", invite); this.set("invite", invite);
}, },
setAutogenerated(value) {
if (this.invites && (this.autogenerated || !this.invite.id) && !value) {
this.invites.unshiftObject(this.invite);
}
this.set("autogenerated", value);
},
save(opts) { save(opts) {
const data = { ...this.buffered.buffer }; const data = { ...this.buffered.buffer };
@ -101,29 +101,37 @@ export default Controller.extend(
.save(data) .save(data)
.then((result) => { .then((result) => {
this.rollbackBuffer(); this.rollbackBuffer();
this.setAutogenerated(opts.autogenerated);
if (
this.invites &&
!this.invites.any((i) => i.id === this.invite.id)
) {
this.invites.unshiftObject(this.invite);
}
if (result.warnings) { if (result.warnings) {
this.appEvents.trigger("modal-body:flash", { this.setProperties({
text: result.warnings.join(","), flashText: result.warnings.join(","),
messageClass: "warning", flashClass: "warning",
flashLink: !this.editing,
}); });
} else if (!this.autogenerated) { } else {
if (this.isEmail && opts.sendEmail) { if (this.isEmail && opts.sendEmail) {
this.send("closeModal"); this.send("closeModal");
} else { } else {
this.appEvents.trigger("modal-body:flash", { this.setProperties({
text: opts.copy flashText: I18n.t("user.invited.invite.invite_saved"),
? I18n.t("user.invited.invite.invite_copied") flashClass: "success",
: I18n.t("user.invited.invite.invite_saved"), flashLink: !this.editing,
messageClass: "success",
}); });
} }
} }
}) })
.catch((e) => .catch((e) =>
this.appEvents.trigger("modal-body:flash", { this.setProperties({
text: extractError(e), flashText: extractError(e),
messageClass: "error", flashClass: "error",
flashLink: false,
}) })
); );
}, },
@ -155,11 +163,6 @@ export default Controller.extend(
return staff || groups.any((g) => g.owner); return staff || groups.any((g) => g.owner);
}, },
@discourseComputed("currentUser.staff", "isEmail", "canInviteToGroup")
hasAdvanced(staff, isEmail, canInviteToGroup) {
return staff || isEmail || canInviteToGroup;
},
@action @action
copied() { copied() {
this.save({ sendEmail: false, copy: true }); this.save({ sendEmail: false, copy: true });
@ -178,10 +181,5 @@ 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");
},
} }
); );

View File

@ -128,15 +128,11 @@ export default Controller.extend(
inviteUsers() { inviteUsers() {
this.set("showNotifyUsers", false); this.set("showNotifyUsers", false);
const controller = showModal("create-invite"); const controller = showModal("create-invite");
controller.setProperties({ controller.set("inviteToTopic", true);
showAdvanced: true,
inviteToTopic: true,
});
controller.buffered.setProperties({ controller.buffered.setProperties({
topicId: this.topic.id, topicId: this.topic.id,
topicTitle: this.topic.title, topicTitle: this.topic.title,
}); });
controller.save({ autogenerated: true });
}, },
} }
); );

View File

@ -66,7 +66,6 @@ export default Controller.extend({
createInvite() { createInvite() {
const controller = showModal("create-invite"); const controller = showModal("create-invite");
controller.set("invites", this.model.invites); controller.set("invites", this.model.invites);
controller.save({ autogenerated: true });
}, },
@action @action
@ -77,7 +76,7 @@ export default Controller.extend({
@action @action
editInvite(invite) { editInvite(invite) {
const controller = showModal("create-invite"); const controller = showModal("create-invite");
controller.set("showAdvanced", true); controller.set("editing", true);
controller.setInvite(invite); controller.setInvite(invite);
}, },

View File

@ -32,9 +32,7 @@ export default DiscourseRoute.extend({
showInviteModal() { showInviteModal() {
const model = this.modelFor("group"); const model = this.modelFor("group");
const controller = showModal("create-invite"); const controller = showModal("create-invite");
controller.set("showAdvanced", true);
controller.buffered.set("groupIds", [model.id]); controller.buffered.set("groupIds", [model.id]);
controller.save({ autogenerated: true });
}, },
@action @action

View File

@ -1,21 +1,40 @@
{{#d-modal-body title=(if invite.id "user.invited.invite.edit_title" "user.invited.invite.new_title")}} {{#if flashText}}
<form> <div id="modal-alert" role="alert" class="alert alert-{{flashClass}}">
<div class="input-group invite-link"> {{#if flashLink}}
<label for="invite-link">{{i18n "user.invited.invite.instructions"}}</label> <div class="input-group invite-link">
<div class="link-share-container"> <label for="invite-link">{{flashText}} {{i18n "user.invited.invite.instructions"}}</label>
{{input <div class="link-share-container">
name="invite-link" {{input
class="invite-link" name="invite-link"
value=invite.link class="invite-link"
readonly=true value=invite.link
}} readonly=true
{{copy-button selector="input.invite-link" copied=(action "copied")}} }}
{{copy-button selector="input.invite-link" copied=(action "copied")}}
</div>
</div> </div>
</div> {{else}}
{{flashText}}
{{/if}}
</div>
{{/if}}
<div class="input-group input-expires-at"> {{#d-modal-body title=(if editing "user.invited.invite.edit_title" "user.invited.invite.new_title")}}
<label>{{d-icon "far-clock"}}{{expiresAtLabel}}</label> <form>
</div> {{#if editing}}
<div class="input-group invite-link">
<label for="invite-link">{{i18n "user.invited.invite.instructions"}}</label>
<div class="link-share-container">
{{input
name="invite-link"
class="invite-link"
value=invite.link
readonly=true
}}
{{copy-button selector="input.invite-link" copied=(action "copied")}}
</div>
</div>
{{/if}}
<div class="input-group input-email"> <div class="input-group input-email">
<label for="invite-email">{{d-icon "envelope"}}{{i18n "user.invited.invite.restrict_email"}}</label> <label for="invite-email">{{d-icon "envelope"}}{{i18n "user.invited.invite.restrict_email"}}</label>
@ -48,66 +67,64 @@
</div> </div>
{{/if}} {{/if}}
{{#if showAdvanced}} {{#if isEmail}}
{{#if isEmail}} <div class="input-group invite-custom-message">
<div class="input-group invite-custom-message"> <label for="invite-message">{{i18n "user.invited.invite.custom_message"}}</label>
<label for="invite-message">{{d-icon "envelope"}}{{i18n "user.invited.invite.custom_message"}}</label> {{textarea id="invite-message" value=buffered.custom_message}}
{{textarea id="invite-message" value=buffered.custom_message}} </div>
</div>
{{/if}}
{{/if}} {{/if}}
{{#if showAdvanced}} {{#if currentUser.staff}}
{{#if currentUser.staff}} <div class="input-group invite-to-topic">
<div class="input-group invite-to-topic"> {{choose-topic
{{choose-topic selectedTopicId=buffered.topicId
selectedTopicId=buffered.topicId topicTitle=buffered.topicTitle
topicTitle=buffered.topicTitle additionalFilters="status:public"
additionalFilters="status:public" labelIcon="hand-point-right"
labelIcon="hand-point-right" label="user.invited.invite.invite_to_topic"
label="user.invited.invite.invite_to_topic" }}
}} </div>
</div> {{else if buffered.topicTitle}}
{{else if buffered.topicTitle}} <div class="input-group invite-to-topic">
<div class="input-group invite-to-topic"> <label for="invite-topic">{{d-icon "hand-point-right"}}{{i18n "user.invited.invite.invite_to_topic"}}</label>
<label for="invite-topic">{{d-icon "hand-point-right"}}{{i18n "user.invited.invite.invite_to_topic"}}</label> {{input
{{input name="invite-topic"
name="invite-topic" class="invite-topic"
class="invite-topic" value=buffered.topicTitle
value=buffered.topicTitle readonly=true
readonly=true }}
}} </div>
</div>
{{/if}}
{{/if}} {{/if}}
{{#if showAdvanced}} {{#if canInviteToGroup}}
{{#if canInviteToGroup}} <div class="input-group invite-to-groups">
<div class="input-group invite-to-groups"> <label>{{d-icon "users"}}{{i18n "user.invited.invite.add_to_groups"}}</label>
<label>{{d-icon "users"}}{{i18n "user.invited.invite.add_to_groups"}}</label> {{group-chooser
{{group-chooser content=allGroups
content=allGroups value=buffered.groupIds
value=buffered.groupIds labelProperty="name"
labelProperty="name" onChange=(action (mut buffered.groupIds))
onChange=(action (mut buffered.groupIds)) }}
}} </div>
</div>
{{/if}}
{{/if}} {{/if}}
{{#if showAdvanced}} {{#if currentUser.staff}}
{{#if currentUser.staff}} <div class="input-group invite-expires-at">
<div class="input-group invite-expires-at"> {{future-date-input
{{future-date-input displayLabelIcon="far-clock"
displayLabelIcon="far-clock" displayLabel=(i18n "user.invited.invite.expires_at")
displayLabel=(i18n "user.invited.invite.expires_at") statusType="close"
includeDateTime=true includeDateTime=true
includeMidFuture=true includeMidFuture=true
clearable=true clearable=true
onChangeInput=(action (mut buffered.expires_at)) input=buffered.expires_at
}} onChangeInput=(action (mut buffered.expires_at))
</div> }}
{{/if}} </div>
{{else}}
<div class="input-group input-expires-at">
<label>{{d-icon "far-clock"}}{{expiresAtLabel}}</label>
</div>
{{/if}} {{/if}}
</form> </form>
{{/d-modal-body}} {{/d-modal-body}}
@ -120,21 +137,12 @@
action=(action "saveInvite") action=(action "saveInvite")
}} }}
{{#if isEmail}} {{d-button
{{d-button icon="envelope"
icon="envelope" label=(if invite.emailed "user.invited.reinvite" "user.invited.invite.send_invite_email")
label=(if invite.emailed "user.invited.reinvite" "user.invited.invite.send_invite_email") class="btn-primary send-invite"
class="btn-primary send-invite" action=(action "saveInvite" true)
action=(action "saveInvite" true) title=(unless isEmail "user.invited.invite.send_invite_email_instructions")
}} disabled=(not isEmail)
{{/if}} }}
{{#if hasAdvanced}}
{{d-button
action=(action "toggleAdvanced")
class="btn-default show-advanced"
icon="cog"
title=(if showAdvanced "user.invited.invite.hide_advanced" "user.invited.invite.show_advanced")
}}
{{/if}}
</div> </div>

View File

@ -4,15 +4,12 @@ import {
count, count,
exists, exists,
fakeTime, fakeTime,
query,
queryAll, queryAll,
} from "discourse/tests/helpers/qunit-helpers"; } from "discourse/tests/helpers/qunit-helpers";
import { test } from "qunit";
import I18n from "I18n"; import I18n from "I18n";
import { test } from "qunit";
acceptance("Invites - Create & Edit Invite Modal", function (needs) { acceptance("Invites - Create & Edit Invite Modal", function (needs) {
let deleted;
needs.user(); needs.user();
needs.pretender((server, helper) => { needs.pretender((server, helper) => {
const inviteData = { const inviteData = {
@ -42,30 +39,17 @@ acceptance("Invites - Create & Edit Invite Modal", function (needs) {
}); });
server.delete("/invites", () => { server.delete("/invites", () => {
deleted = true;
return helper.response({}); return helper.response({});
}); });
}); });
needs.hooks.beforeEach(() => {
deleted = false;
});
test("basic functionality", async function (assert) { test("basic functionality", async function (assert) {
await visit("/u/eviltrout/invited/pending"); await visit("/u/eviltrout/invited/pending");
await click(".user-invite-buttons .btn:first-child"); await click(".user-invite-buttons .btn:first-child");
assert.strictEqual(
query("input.invite-link").value,
"http://example.com/invites/52641ae8878790bc7b79916247cfe6ba",
"shows an invite link when modal is opened"
);
await click(".modal-footer .show-advanced"); await assert.ok(exists(".invite-to-groups"));
await assert.ok(exists(".invite-to-groups"), "shows advanced options"); await assert.ok(exists(".invite-to-topic"));
await assert.ok(exists(".invite-to-topic"), "shows advanced options"); await assert.ok(exists(".invite-expires-at"));
await assert.ok(exists(".invite-expires-at"), "shows advanced options");
await click(".modal-close");
assert.ok(deleted, "deletes the invite if not saved");
}); });
test("saving", async function (assert) { test("saving", async function (assert) {
@ -81,31 +65,14 @@ acceptance("Invites - Create & Edit Invite Modal", function (needs) {
1, 1,
"adds invite to list after saving" "adds invite to list after saving"
); );
await click(".modal-close");
assert.notOk(deleted, "does not delete invite on close");
}); });
test("copying saves invite", async function (assert) { test("copying saves invite", async function (assert) {
await visit("/u/eviltrout/invited/pending"); await visit("/u/eviltrout/invited/pending");
await click(".user-invite-buttons .btn:first-child"); await click(".user-invite-buttons .btn:first-child");
await click(".invite-link .btn"); await click(".save-invite");
assert.ok(exists(".invite-link .btn"));
await click(".modal-close");
assert.notOk(deleted, "does not delete invite on close");
});
test("copying an email invite without an email shows error message", async function (assert) {
await visit("/u/eviltrout/invited/pending");
await click(".user-invite-buttons .btn:first-child");
await fillIn("#invite-email", "error");
await click(".invite-link .btn");
assert.strictEqual(
query("#modal-alert").innerText,
"error isn't a valid email address."
);
}); });
}); });
@ -159,7 +126,10 @@ acceptance("Invites - Email Invites", function (needs) {
groups: [], groups: [],
}; };
server.post("/invites", () => helper.response(inviteData)); server.post("/invites", (request) => {
lastRequest = request;
return helper.response(inviteData);
});
server.put("/invites/1", (request) => { server.put("/invites/1", (request) => {
lastRequest = request; lastRequest = request;
@ -232,7 +202,6 @@ acceptance(
await visit("/u/eviltrout/invited/pending"); await visit("/u/eviltrout/invited/pending");
await click(".user-invite-buttons .btn:first-child"); await click(".user-invite-buttons .btn:first-child");
await click(".modal-footer .show-advanced");
await click(".future-date-input-selector-header"); await click(".future-date-input-selector-header");
const options = Array.from( const options = Array.from(

View File

@ -182,9 +182,10 @@
} }
} }
.show-advanced { .invite-custom-message {
margin-left: auto; label {
margin-right: 0; margin-left: 1.75em;
}
} }
.input-group { .input-group {
@ -198,5 +199,16 @@
margin-left: 1.75em; margin-left: 1.75em;
width: calc(100% - 1.75em); width: calc(100% - 1.75em);
} }
.future-date-input-date-picker,
.future-date-input-time-picker {
display: inline-block;
margin: 0em 0em 0em 1.75em;
width: calc(50% - 2em);
input {
height: 34px;
}
}
} }
} }

View File

@ -111,6 +111,11 @@
.create-invite-modal, .create-invite-modal,
.create-invite-bulk-modal, .create-invite-bulk-modal,
.share-topic-modal { .share-topic-modal {
&.modal .modal-body {
margin: 1em;
padding: unset;
}
.modal-inner-container { .modal-inner-container {
width: 40em; // scale with user font-size width: 40em; // scale with user font-size
max-width: 100vw; // prevent overflow if user font-size is enourmous max-width: 100vw; // prevent overflow if user font-size is enourmous

View File

@ -1613,7 +1613,7 @@ en:
new_title: "Create Invite" new_title: "Create Invite"
edit_title: "Edit Invite" edit_title: "Edit Invite"
instructions: "Share this link to instantly grant access to this site" instructions: "Share this link to instantly grant access to this site:"
copy_link: "copy link" copy_link: "copy link"
expires_in_time: "Expires in %{time}" expires_in_time: "Expires in %{time}"
expired_at_time: "Expired at %{time}" expired_at_time: "Expired at %{time}"
@ -1621,20 +1621,20 @@ en:
show_advanced: "Show Advanced Options" show_advanced: "Show Advanced Options"
hide_advanced: "Hide Advanced Options" hide_advanced: "Hide Advanced Options"
restrict_email: "Restrict to one email address" restrict_email: "Restrict to email"
max_redemptions_allowed: "Max uses" max_redemptions_allowed: "Max uses"
add_to_groups: "Add to groups" add_to_groups: "Add to groups"
invite_to_topic: "Arrive at this topic" invite_to_topic: "Arrive at topic"
expires_at: "Expire after" expires_at: "Expire after"
custom_message: "Optional personal message" custom_message: "Optional personal message"
send_invite_email: "Save and Send Email" send_invite_email: "Save and Send Email"
send_invite_email_instructions: "Restrict invite to email to send an invite email"
save_invite: "Save Invite" save_invite: "Save Invite"
invite_saved: "Invite saved." invite_saved: "Invite saved."
invite_copied: "Invite link copied."
bulk_invite: bulk_invite:
none: "No invitations to display on this page." none: "No invitations to display on this page."

View File

@ -586,6 +586,7 @@ users:
default: true default: true
invite_expiry_days: invite_expiry_days:
default: 30 default: 30
client: true
max: 36500 max: 36500
invites_per_page: invites_per_page:
client: true client: true