From a5497b74be76c9250c59ed12d516f9e8bbbca5ef Mon Sep 17 00:00:00 2001 From: Osama Sayegh Date: Mon, 21 Oct 2024 13:11:43 +0300 Subject: [PATCH] UX: Simplify invite modal (#28974) This commit simplifies the initial state of the invite modal when it's opened to make it one click away from creating an invite link. The existing options/fields within the invite modal are still available, but are now hidden behind an advanced mode which can be enabled. On the technical front, this PR also switches the invite modal to use our FormKit library. Internal topic: t/134023. --- .../discourse/app/components/copy-button.hbs | 3 +- .../discourse/app/components/copy-button.js | 8 + .../discourse/app/components/d-modal.gjs | 2 +- .../app/components/future-date-input.hbs | 32 +- .../app/components/future-date-input.js | 2 +- .../app/components/modal/create-invite.gjs | 503 ++++++++++++++++++ .../app/components/modal/create-invite.hbs | 204 ------- .../app/components/modal/create-invite.js | 209 -------- .../discourse/app/lib/pwa-utils.js | 37 +- .../app/templates/user-invited-show.hbs | 2 +- .../acceptance/create-invite-modal-test.js | 290 ---------- app/assets/stylesheets/common/base/modal.scss | 4 + .../stylesheets/common/base/share_link.scss | 107 +--- app/assets/stylesheets/mobile/modal.scss | 4 - config/locales/client.en.yml | 30 +- spec/system/create_invite_spec.rb | 297 +++++++++++ .../page_objects/components/form_kit.rb | 15 +- spec/system/page_objects/modals/base.rb | 12 + .../page_objects/modals/create_invite.rb | 57 ++ .../pages/user_invited_pending.rb | 73 +++ 20 files changed, 1042 insertions(+), 849 deletions(-) create mode 100644 app/assets/javascripts/discourse/app/components/modal/create-invite.gjs delete mode 100644 app/assets/javascripts/discourse/app/components/modal/create-invite.hbs delete mode 100644 app/assets/javascripts/discourse/app/components/modal/create-invite.js delete mode 100644 app/assets/javascripts/discourse/tests/acceptance/create-invite-modal-test.js create mode 100644 spec/system/create_invite_spec.rb create mode 100644 spec/system/page_objects/modals/create_invite.rb create mode 100644 spec/system/page_objects/pages/user_invited_pending.rb diff --git a/app/assets/javascripts/discourse/app/components/copy-button.hbs b/app/assets/javascripts/discourse/app/components/copy-button.hbs index 620a20a017b..3eccc6b803f 100644 --- a/app/assets/javascripts/discourse/app/components/copy-button.hbs +++ b/app/assets/javascripts/discourse/app/components/copy-button.hbs @@ -1,6 +1,7 @@ \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/copy-button.js b/app/assets/javascripts/discourse/app/components/copy-button.js index 10f6bdc08df..83d2864d500 100644 --- a/app/assets/javascripts/discourse/app/components/copy-button.js +++ b/app/assets/javascripts/discourse/app/components/copy-button.js @@ -9,6 +9,12 @@ export default class CopyButton extends Component { copyIcon = "copy"; copyClass = "btn-primary"; + init() { + super.init(...arguments); + + this.copyTranslatedLabel = this.translatedLabel; + } + @bind _restoreButton() { if (this.isDestroying || this.isDestroyed) { @@ -17,6 +23,7 @@ export default class CopyButton extends Component { this.set("copyIcon", "copy"); this.set("copyClass", "btn-primary"); + this.set("copyTranslatedLabel", this.translatedLabel); } @action @@ -34,6 +41,7 @@ export default class CopyButton extends Component { this.set("copyIcon", "check"); this.set("copyClass", "btn-primary ok"); + this.set("copyTranslatedLabel", this.translatedLabelAfterCopy); discourseDebounce(this._restoreButton, 3000); } catch (err) {} diff --git a/app/assets/javascripts/discourse/app/components/d-modal.gjs b/app/assets/javascripts/discourse/app/components/d-modal.gjs index b2a80750df6..3956a7991a7 100644 --- a/app/assets/javascripts/discourse/app/components/d-modal.gjs +++ b/app/assets/javascripts/discourse/app/components/d-modal.gjs @@ -371,7 +371,7 @@ export default class DModal extends Component { {{/if}} - {{#if (has-block "footer")}} + {{#if (and (has-block "footer") (not @hideFooter))}} diff --git a/app/assets/javascripts/discourse/app/components/future-date-input.hbs b/app/assets/javascripts/discourse/app/components/future-date-input.hbs index 9732ee59dfc..c151ce42169 100644 --- a/app/assets/javascripts/discourse/app/components/future-date-input.hbs +++ b/app/assets/javascripts/discourse/app/components/future-date-input.hbs @@ -1,19 +1,21 @@
-
- - -
+ {{#unless this.noRelativeOptions}} +
+ + +
+ {{/unless}} {{#if this.displayDateAndTimePicker}}
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 47bb9a2fcd0..a89db34ee38 100644 --- a/app/assets/javascripts/discourse/app/components/future-date-input.js +++ b/app/assets/javascripts/discourse/app/components/future-date-input.js @@ -42,7 +42,7 @@ export default class FutureDateInput extends Component { if (this.input) { const dateTime = moment(this.input); const closestShortcut = this._findClosestShortcut(dateTime); - if (closestShortcut) { + if (!this.noRelativeOptions && closestShortcut) { this.set("selection", closestShortcut.id); } else { this.setProperties({ diff --git a/app/assets/javascripts/discourse/app/components/modal/create-invite.gjs b/app/assets/javascripts/discourse/app/components/modal/create-invite.gjs new file mode 100644 index 00000000000..62baabbfbaa --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/modal/create-invite.gjs @@ -0,0 +1,503 @@ +import Component from "@glimmer/component"; +import { cached, tracked } from "@glimmer/tracking"; +import { fn, hash } from "@ember/helper"; +import { on } from "@ember/modifier"; +import { action } from "@ember/object"; +import { service } from "@ember/service"; +import { htmlSafe } from "@ember/template"; +import { and, notEq, or } from "truth-helpers"; +import CopyButton from "discourse/components/copy-button"; +import DButton from "discourse/components/d-button"; +import DModal from "discourse/components/d-modal"; +import Form from "discourse/components/form"; +import FutureDateInput from "discourse/components/future-date-input"; +import { extractError } from "discourse/lib/ajax-error"; +import { canNativeShare, nativeShare } from "discourse/lib/pwa-utils"; +import { sanitize } from "discourse/lib/text"; +import { emailValid, hostnameValid } from "discourse/lib/utilities"; +import Group from "discourse/models/group"; +import Invite from "discourse/models/invite"; +import i18n from "discourse-common/helpers/i18n"; +import I18n from "discourse-i18n"; +import { FORMAT as DATE_INPUT_FORMAT } from "select-kit/components/future-date-input-selector"; +import GroupChooser from "select-kit/components/group-chooser"; +import TopicChooser from "select-kit/components/topic-chooser"; + +export default class CreateInvite extends Component { + @service capabilities; + @service currentUser; + @service siteSettings; + @service site; + + @tracked saving = false; + @tracked displayAdvancedOptions = false; + + @tracked flashText; + @tracked flashClass = "info"; + + @tracked topics = this.invite.topics ?? this.model.topics ?? []; + @tracked allGroups; + + model = this.args.model; + invite = this.model.invite ?? Invite.create(); + formApi; + + constructor() { + super(...arguments); + + Group.findAll().then((groups) => { + this.allGroups = groups.filter((group) => !group.automatic); + }); + } + + get linkValidityMessageFormat() { + return I18n.messageFormat("user.invited.invite.link_validity_MF", { + user_count: this.defaultRedemptionsAllowed, + duration_days: this.siteSettings.invite_expiry_days, + }); + } + + get expireAfterOptions() { + let list = [1, 7, 30, 90]; + + if (!list.includes(this.siteSettings.invite_expiry_days)) { + list.push(this.siteSettings.invite_expiry_days); + } + + list = list + .sort((a, b) => a - b) + .map((days) => { + return { + value: days, + text: I18n.t("dates.medium.x_days", { count: days }), + }; + }); + + list.push({ + value: 999999, + text: I18n.t("time_shortcut.never"), + }); + + return list; + } + + @cached + get data() { + const data = { + restrictTo: this.invite.emailOrDomain ?? "", + maxRedemptions: + this.invite.max_redemptions_allowed ?? this.defaultRedemptionsAllowed, + inviteToTopic: this.invite.topicId, + inviteToGroups: this.model.groupIds ?? this.invite.groupIds ?? [], + customMessage: this.invite.custom_message ?? "", + }; + + if (this.inviteCreated) { + data.expiresAt = this.invite.expires_at; + } else { + data.expiresAfterDays = this.siteSettings.invite_expiry_days; + } + + return data; + } + + async save(data) { + let isLink = true; + let isEmail = false; + + if (data.emailOrDomain) { + if (emailValid(data.emailOrDomain)) { + isEmail = true; + isLink = false; + data.email = data.emailOrDomain; + } else if (hostnameValid(data.emailOrDomain)) { + data.domain = data.emailOrDomain; + } + delete data.emailOrDomain; + } + + if (isLink) { + if (this.invite.email) { + data.email = data.custom_message = ""; + } + } else if (isEmail) { + if (data.max_redemptions_allowed > 1) { + data.max_redemptions_allowed = 1; + } + + data.send_email = true; + if (data.topic_id) { + data.invite_to_topic = true; + } + } + + this.saving = true; + try { + await this.invite.save(data); + const invites = this.model?.invites; + if (invites && !invites.some((i) => i.id === this.invite.id)) { + invites.unshiftObject(this.invite); + } + + if (!this.simpleMode) { + this.flashText = sanitize(I18n.t("user.invited.invite.invite_saved")); + this.flashClass = "success"; + } + } catch (error) { + this.flashText = sanitize(extractError(error)); + this.flashClass = "error"; + } finally { + this.saving = false; + } + } + + get maxRedemptionsAllowedLimit() { + if (this.currentUser.staff) { + return this.siteSettings.invite_link_max_redemptions_limit; + } + + return this.siteSettings.invite_link_max_redemptions_limit_users; + } + + get defaultRedemptionsAllowed() { + const max = this.maxRedemptionsAllowedLimit; + const val = this.currentUser.staff ? 100 : 10; + return Math.min(max, val); + } + + get canInviteToGroup() { + return ( + this.currentUser.staff || + this.currentUser.groups.some((g) => g.group_user?.owner) + ); + } + + get canArriveAtTopic() { + return this.currentUser.staff && !this.siteSettings.must_approve_users; + } + + @action + async onFormSubmit(data) { + const submitData = { + emailOrDomain: data.restrictTo?.trim(), + group_ids: data.inviteToGroups, + topic_id: data.inviteToTopic, + max_redemptions_allowed: data.maxRedemptions, + custom_message: data.customMessage, + }; + + if (data.expiresAt) { + submitData.expires_at = data.expiresAt; + } else if (data.expiresAfterDays) { + submitData.expires_at = moment() + .add(data.expiresAfterDays, "days") + .format(DATE_INPUT_FORMAT); + } + + await this.save(submitData); + } + + @action + saveInvite() { + this.formApi.submit(); + } + + @action + onChangeTopic(fieldSet, topicId, topic) { + this.topics = [topic]; + fieldSet(topicId); + } + + @action + showAdvancedMode() { + this.displayAdvancedOptions = true; + } + + get simpleMode() { + return !this.args.model.editing && !this.displayAdvancedOptions; + } + + get inviteCreated() { + // use .get to track the id + return !!this.invite.get("id"); + } + + @action + async createLink() { + await this.save({ + max_redemptions_allowed: this.defaultRedemptionsAllowed, + expires_at: moment() + .add(this.siteSettings.invite_expiry_days, "days") + .format(DATE_INPUT_FORMAT), + }); + } + + @action + cancel() { + this.args.closeModal(); + } + + @action + registerApi(api) { + this.formApi = api; + } + + +} + +const InviteModalAlert = ; + +class ShareOrCopyInviteLink extends Component { + @service capabilities; + + @action + async nativeShare() { + await nativeShare(this.capabilities, { url: this.args.invite.link }); + } + + +} diff --git a/app/assets/javascripts/discourse/app/components/modal/create-invite.hbs b/app/assets/javascripts/discourse/app/components/modal/create-invite.hbs deleted file mode 100644 index 3bed9058866..00000000000 --- a/app/assets/javascripts/discourse/app/components/modal/create-invite.hbs +++ /dev/null @@ -1,204 +0,0 @@ - - <:belowHeader> - {{#if this.flashText}} - - {{/if}} - - <:body> -
- {{#if this.editing}} - - {{/if}} - -
- -
- - {{#if this.capabilities.hasContactPicker}} - - {{/if}} -
-
- - {{#if this.isLink}} -
- - -
- {{/if}} - - {{#if this.isEmail}} -
- -