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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,15 +4,12 @@ import {
count,
exists,
fakeTime,
query,
queryAll,
} from "discourse/tests/helpers/qunit-helpers";
import { test } from "qunit";
import I18n from "I18n";
import { test } from "qunit";
acceptance("Invites - Create & Edit Invite Modal", function (needs) {
let deleted;
needs.user();
needs.pretender((server, helper) => {
const inviteData = {
@ -42,30 +39,17 @@ acceptance("Invites - Create & Edit Invite Modal", function (needs) {
});
server.delete("/invites", () => {
deleted = true;
return helper.response({});
});
});
needs.hooks.beforeEach(() => {
deleted = false;
});
test("basic functionality", async function (assert) {
await visit("/u/eviltrout/invited/pending");
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"), "shows advanced options");
await assert.ok(exists(".invite-to-topic"), "shows advanced options");
await assert.ok(exists(".invite-expires-at"), "shows advanced options");
await click(".modal-close");
assert.ok(deleted, "deletes the invite if not saved");
await assert.ok(exists(".invite-to-groups"));
await assert.ok(exists(".invite-to-topic"));
await assert.ok(exists(".invite-expires-at"));
});
test("saving", async function (assert) {
@ -81,31 +65,14 @@ acceptance("Invites - Create & Edit Invite Modal", function (needs) {
1,
"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) {
await visit("/u/eviltrout/invited/pending");
await click(".user-invite-buttons .btn:first-child");
await click(".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."
);
await click(".save-invite");
assert.ok(exists(".invite-link .btn"));
});
});
@ -159,7 +126,10 @@ acceptance("Invites - Email Invites", function (needs) {
groups: [],
};
server.post("/invites", () => helper.response(inviteData));
server.post("/invites", (request) => {
lastRequest = request;
return helper.response(inviteData);
});
server.put("/invites/1", (request) => {
lastRequest = request;
@ -232,7 +202,6 @@ acceptance(
await visit("/u/eviltrout/invited/pending");
await click(".user-invite-buttons .btn:first-child");
await click(".modal-footer .show-advanced");
await click(".future-date-input-selector-header");
const options = Array.from(

View File

@ -182,9 +182,10 @@
}
}
.show-advanced {
margin-left: auto;
margin-right: 0;
.invite-custom-message {
label {
margin-left: 1.75em;
}
}
.input-group {
@ -198,5 +199,16 @@
margin-left: 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-bulk-modal,
.share-topic-modal {
&.modal .modal-body {
margin: 1em;
padding: unset;
}
.modal-inner-container {
width: 40em; // scale with user font-size
max-width: 100vw; // prevent overflow if user font-size is enourmous

View File

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

View File

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