From 6ae065f9cd101ff14c3fda77d41b05364cae80d6 Mon Sep 17 00:00:00 2001 From: Dan Ungureanu Date: Thu, 18 Nov 2021 20:19:02 +0200 Subject: [PATCH] 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. --- .../app/components/future-date-input.js | 52 ++++- .../app/controllers/create-invite.js | 78 ++++---- .../discourse/app/controllers/share-topic.js | 6 +- .../app/controllers/user-invited-show.js | 3 +- .../discourse/app/routes/group-index.js | 2 - .../app/templates/modal/create-invite.hbs | 182 +++++++++--------- .../acceptance/create-invite-modal-test.js | 51 +---- .../stylesheets/common/base/share_link.scss | 18 +- app/assets/stylesheets/desktop/modal.scss | 5 + config/locales/client.en.yml | 8 +- config/site_settings.yml | 1 + 11 files changed, 213 insertions(+), 193 deletions(-) diff --git a/app/assets/javascripts/discourse/app/components/future-date-input.js b/app/assets/javascripts/discourse/app/components/future-date-input.js index f673f73ae5..3f17a595ec 100644 --- a/app/assets/javascripts/discourse/app/components/future-date-input.js +++ b/app/assets/javascripts/discourse/app/components/future-date-input.js @@ -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; + } + }); + }, }); diff --git a/app/assets/javascripts/discourse/app/controllers/create-invite.js b/app/assets/javascripts/discourse/app/controllers/create-invite.js index 1a77404237..2a4cb7e841 100644 --- a/app/assets/javascripts/discourse/app/controllers/create-invite.js +++ b/app/assets/javascripts/discourse/app/controllers/create-invite.js @@ -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"); - }, } ); diff --git a/app/assets/javascripts/discourse/app/controllers/share-topic.js b/app/assets/javascripts/discourse/app/controllers/share-topic.js index d1c3657be0..e3cf0ed5c9 100644 --- a/app/assets/javascripts/discourse/app/controllers/share-topic.js +++ b/app/assets/javascripts/discourse/app/controllers/share-topic.js @@ -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 }); }, } ); diff --git a/app/assets/javascripts/discourse/app/controllers/user-invited-show.js b/app/assets/javascripts/discourse/app/controllers/user-invited-show.js index f7f98519a9..50c36460c1 100644 --- a/app/assets/javascripts/discourse/app/controllers/user-invited-show.js +++ b/app/assets/javascripts/discourse/app/controllers/user-invited-show.js @@ -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); }, diff --git a/app/assets/javascripts/discourse/app/routes/group-index.js b/app/assets/javascripts/discourse/app/routes/group-index.js index 1a9ff92a92..c128678090 100644 --- a/app/assets/javascripts/discourse/app/routes/group-index.js +++ b/app/assets/javascripts/discourse/app/routes/group-index.js @@ -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 diff --git a/app/assets/javascripts/discourse/app/templates/modal/create-invite.hbs b/app/assets/javascripts/discourse/app/templates/modal/create-invite.hbs index 6754afe8d7..d31844c484 100644 --- a/app/assets/javascripts/discourse/app/templates/modal/create-invite.hbs +++ b/app/assets/javascripts/discourse/app/templates/modal/create-invite.hbs @@ -1,21 +1,40 @@ -{{#d-modal-body title=(if invite.id "user.invited.invite.edit_title" "user.invited.invite.new_title")}} -
-
diff --git a/app/assets/javascripts/discourse/tests/acceptance/create-invite-modal-test.js b/app/assets/javascripts/discourse/tests/acceptance/create-invite-modal-test.js index 27d6abfe89..f4e577baab 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/create-invite-modal-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/create-invite-modal-test.js @@ -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( diff --git a/app/assets/stylesheets/common/base/share_link.scss b/app/assets/stylesheets/common/base/share_link.scss index 14cc953260..396d2c8a1c 100644 --- a/app/assets/stylesheets/common/base/share_link.scss +++ b/app/assets/stylesheets/common/base/share_link.scss @@ -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; + } + } } } diff --git a/app/assets/stylesheets/desktop/modal.scss b/app/assets/stylesheets/desktop/modal.scss index 3c7368df84..c4a54245d2 100644 --- a/app/assets/stylesheets/desktop/modal.scss +++ b/app/assets/stylesheets/desktop/modal.scss @@ -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 diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index bb9066166b..7fe17831b6 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -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." diff --git a/config/site_settings.yml b/config/site_settings.yml index a927de4c5f..a1bad6eab0 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -586,6 +586,7 @@ users: default: true invite_expiry_days: default: 30 + client: true max: 36500 invites_per_page: client: true