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.
This commit is contained in:
Osama Sayegh 2024-10-21 13:11:43 +03:00 committed by GitHub
parent b1321b985a
commit a5497b74be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1042 additions and 849 deletions

View File

@ -1,6 +1,7 @@
<DButton
@icon={{this.copyIcon}}
@action={{this.copy}}
class={{this.copyClass}}
class="copy-button {{this.copyClass}}"
@ariaLabel={{this.ariaLabel}}
@translatedLabel={{this.copyTranslatedLabel}}
/>

View File

@ -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) {}

View File

@ -371,7 +371,7 @@ export default class DModal extends Component {
{{/if}}
</div>
{{#if (has-block "footer")}}
{{#if (and (has-block "footer") (not @hideFooter))}}
<div class="d-modal__footer">
{{yield to="footer"}}
</div>

View File

@ -1,19 +1,21 @@
<div class="future-date-input">
<div class="control-group">
<label class={{this.labelClasses}}>
{{#if this.displayLabelIcon}}{{d-icon
this.displayLabelIcon
}}{{/if}}{{this.displayLabel}}
</label>
<FutureDateInputSelector
@value={{readonly this.selection}}
@content={{this.shortcuts}}
@clearable={{this.clearable}}
@onChangeInput={{this.onChangeInput}}
@onChange={{fn (mut this.selection)}}
@options={{hash none="time_shortcut.select_timeframe"}}
/>
</div>
{{#unless this.noRelativeOptions}}
<div class="control-group">
<label class={{this.labelClasses}}>
{{#if this.displayLabelIcon}}{{d-icon
this.displayLabelIcon
}}{{/if}}{{this.displayLabel}}
</label>
<FutureDateInputSelector
@value={{readonly this.selection}}
@content={{this.shortcuts}}
@clearable={{this.clearable}}
@onChangeInput={{this.onChangeInput}}
@onChange={{fn (mut this.selection)}}
@options={{hash none="time_shortcut.select_timeframe"}}
/>
</div>
{{/unless}}
{{#if this.displayDateAndTimePicker}}
<div class="control-group future-date-input-date-picker">

View File

@ -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({

View File

@ -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;
}
<template>
<DModal
class="create-invite-modal"
@title={{i18n
(if
@model.editing
"user.invited.invite.edit_title"
"user.invited.invite.new_title"
)
}}
@closeModal={{@closeModal}}
@hideFooter={{and this.simpleMode this.inviteCreated}}
>
<:belowHeader>
{{#if (or this.flashText @model.editing)}}
<InviteModalAlert
@invite={{this.invite}}
@alertClass={{this.flashClass}}
@showInviteLink={{and
this.inviteCreated
(notEq this.flashClass "error")
}}
>
{{#if this.flashText}}
{{htmlSafe this.flashText}}
{{/if}}
{{#if (and this.inviteCreated (notEq this.flashClass "error"))}}
{{#if @model.editing}}
{{i18n "user.invited.invite.copy_link_and_share_it"}}
{{else}}
{{i18n "user.invited.invite.instructions"}}
{{/if}}
{{/if}}
</InviteModalAlert>
{{/if}}
</:belowHeader>
<:body>
{{#if this.simpleMode}}
{{#if this.inviteCreated}}
{{#unless this.site.mobileView}}
<p>
{{i18n "user.invited.invite.copy_link_and_share_it"}}
</p>
{{/unless}}
<div class="link-share-container">
<ShareOrCopyInviteLink @invite={{this.invite}} />
</div>
{{else}}
<p>
{{i18n "user.invited.invite.create_link_to_invite"}}
</p>
{{/if}}
<p class="link-limits-info">
{{this.linkValidityMessageFormat}}
<a
class="edit-link-options"
role="button"
{{on "click" this.showAdvancedMode}}
>{{i18n "user.invited.invite.edit_link_options"}}</a>
</p>
{{else}}
<Form
@data={{this.data}}
@onSubmit={{this.onFormSubmit}}
@onRegisterApi={{this.registerApi}}
as |form transientData|
>
<form.Field
@name="restrictTo"
@title={{i18n "user.invited.invite.restrict"}}
@format="large"
as |field|
>
<field.Input
placeholder={{i18n
"user.invited.invite.email_or_domain_placeholder"
}}
/>
</form.Field>
{{#unless (emailValid transientData.restrictTo)}}
<form.Field
@name="maxRedemptions"
@title={{i18n "user.invited.invite.max_redemptions_allowed"}}
@type="number"
@format="small"
@validation="required"
as |field|
>
<field.Input
type="number"
min="1"
max={{this.maxRedemptionsAllowedLimit}}
/>
</form.Field>
{{/unless}}
{{#if this.inviteCreated}}
<form.Field
@name="expiresAt"
@title={{i18n "user.invited.invite.expires_at"}}
@format="large"
@validation="required"
as |field|
>
<field.Custom>
<FutureDateInput
@clearable={{true}}
@input={{field.value}}
@noRelativeOptions={{true}}
@onChangeInput={{field.set}}
/>
</field.Custom>
</form.Field>
{{else}}
<form.Field
@name="expiresAfterDays"
@title={{i18n "user.invited.invite.expires_after"}}
@format="large"
@validation="required"
as |field|
>
<field.Select as |select|>
{{#each this.expireAfterOptions as |option|}}
<select.Option
@value={{option.value}}
>{{option.text}}</select.Option>
{{/each}}
</field.Select>
</form.Field>
{{/if}}
{{#if this.canArriveAtTopic}}
<form.Field
@name="inviteToTopic"
@title={{i18n "user.invited.invite.invite_to_topic"}}
@format="large"
as |field|
>
<field.Custom>
<TopicChooser
@value={{field.value}}
@content={{this.topics}}
@onChange={{fn this.onChangeTopic field.set}}
@options={{hash additionalFilters="status:public"}}
/>
</field.Custom>
</form.Field>
{{/if}}
{{#if this.canInviteToGroup}}
<form.Field
@name="inviteToGroups"
@title={{i18n "user.invited.invite.add_to_groups"}}
@format="large"
as |field|
>
<field.Custom>
<GroupChooser
@content={{this.allGroups}}
@value={{field.value}}
@labelProperty="name"
@onChange={{field.set}}
/>
</field.Custom>
</form.Field>
{{/if}}
{{#if (emailValid transientData.restrictTo)}}
<form.Field
@name="customMessage"
@title={{i18n "user.invited.invite.custom_message"}}
@format="full"
as |field|
>
<field.Textarea
height={{100}}
placeholder={{i18n
"user.invited.invite.custom_message_placeholder"
}}
/>
</form.Field>
{{/if}}
</Form>
{{/if}}
</:body>
<:footer>
{{#if this.simpleMode}}
<DButton
@label="user.invited.invite.create_link"
@action={{this.createLink}}
@disabled={{this.saving}}
class="btn-primary save-invite"
/>
{{else}}
<DButton
@label="user.invited.invite.save_invite"
@action={{this.saveInvite}}
@disabled={{this.saving}}
class="btn-primary save-invite"
/>
{{/if}}
<DButton
@label="user.invited.invite.cancel"
@action={{this.cancel}}
class="btn-transparent cancel-button"
/>
</:footer>
</DModal>
</template>
}
const InviteModalAlert = <template>
<div id="modal-alert" role="alert" class="alert alert-{{@alertClass}}">
<div class="input-group invite-link">
<label for="invite-link">
{{yield}}
</label>
{{#if @showInviteLink}}
<div class="link-share-container">
<ShareOrCopyInviteLink @invite={{@invite}} />
</div>
{{/if}}
</div>
</div>
</template>;
class ShareOrCopyInviteLink extends Component {
@service capabilities;
@action
async nativeShare() {
await nativeShare(this.capabilities, { url: this.args.invite.link });
}
<template>
<input
name="invite-link"
type="text"
class="invite-link"
value={{@invite.link}}
readonly={{true}}
/>
{{#if (canNativeShare this.capabilities)}}
<DButton
class="btn-primary"
@icon="share"
@translatedLabel={{i18n "user.invited.invite.share_link"}}
@action={{this.nativeShare}}
/>
{{else}}
<CopyButton
@selector="input.invite-link"
@translatedLabel={{i18n "user.invited.invite.copy_link"}}
@translatedLabelAfterCopy={{i18n "user.invited.invite.link_copied"}}
/>
{{/if}}
</template>
}

View File

@ -1,204 +0,0 @@
<DModal
class="create-invite-modal"
@title={{i18n
(if
this.editing
"user.invited.invite.edit_title"
"user.invited.invite.new_title"
)
}}
@closeModal={{@closeModal}}
>
<:belowHeader>
{{#if this.flashText}}
<div
id="modal-alert"
role="alert"
class="alert alert-{{this.flashClass}}"
>
{{#if this.flashLink}}
<div class="input-group invite-link">
<label for="invite-link">{{html-safe this.flashText}}
{{i18n "user.invited.invite.instructions"}}</label>
<div class="link-share-container">
<Input
name="invite-link"
class="invite-link"
@value={{this.invite.link}}
readonly={{true}}
/>
<CopyButton
@selector="input.invite-link"
@copied={{action "copied"}}
/>
</div>
</div>
{{else}}
{{html-safe this.flashText}}
{{/if}}
</div>
{{/if}}
</:belowHeader>
<:body>
<form>
{{#if this.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={{this.invite.link}}
readonly={{true}}
/>
<CopyButton
@selector="input.invite-link"
@copied={{action "copied"}}
/>
</div>
</div>
{{/if}}
<div class="input-group input-email">
<label for="invite-email">
{{d-icon "envelope"}}
{{#if this.isEmail}}
{{i18n "user.invited.invite.restrict_email"}}
{{else if this.isDomain}}
{{i18n "user.invited.invite.restrict_domain"}}
{{else}}
{{i18n "user.invited.invite.restrict"}}
{{/if}}
</label>
<div class="invite-email-container">
<Input
id="invite-email"
@value={{this.buffered.emailOrDomain}}
placeholder={{i18n
"user.invited.invite.email_or_domain_placeholder"
}}
/>
{{#if this.capabilities.hasContactPicker}}
<DButton
@icon="address-book"
@action={{this.searchContact}}
class="btn-primary open-contact-picker"
/>
{{/if}}
</div>
</div>
{{#if this.isLink}}
<div class="input-group invite-max-redemptions">
<label for="invite-max-redemptions">{{d-icon "users"}}{{i18n
"user.invited.invite.max_redemptions_allowed"
}}</label>
<Input
id="invite-max-redemptions"
@type="number"
@value={{this.buffered.max_redemptions_allowed}}
min="1"
max={{this.maxRedemptionsAllowedLimit}}
/>
</div>
{{/if}}
{{#if this.isEmail}}
<div class="input-group invite-custom-message">
<label for="invite-message">{{i18n
"user.invited.invite.custom_message"
}}</label>
<Textarea
id="invite-message"
@value={{this.buffered.custom_message}}
/>
</div>
{{/if}}
{{#if this.canArriveAtTopic}}
<div class="input-group invite-to-topic">
<label for="invite-topic">{{d-icon "hand-point-right"}}{{i18n
"user.invited.invite.invite_to_topic"
}}</label>
<TopicChooser
@value={{this.buffered.topicId}}
@content={{this.topics}}
@onChange={{action "onChangeTopic"}}
@options={{hash additionalFilters="status:public"}}
/>
</div>
{{else if this.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={{this.buffered.topicTitle}}
readonly={{true}}
/>
</div>
{{/if}}
{{#if this.canInviteToGroup}}
<div class="input-group invite-to-groups">
<label>{{d-icon "users"}}{{i18n
"user.invited.invite.add_to_groups"
}}</label>
<GroupChooser
@content={{this.allGroups}}
@value={{this.buffered.groupIds}}
@labelProperty="name"
@onChange={{fn (mut this.buffered.groupIds)}}
/>
</div>
{{/if}}
{{#if this.currentUser.staff}}
<div class="input-group invite-expires-at">
<FutureDateInput
@displayLabelIcon="far-clock"
@displayLabel={{i18n "user.invited.invite.expires_at"}}
@customShortcuts={{this.timeShortcuts}}
@clearable={{true}}
@input={{this.buffered.expires_at}}
@onChangeInput={{fn (mut this.buffered.expires_at)}}
/>
</div>
{{else}}
<div class="input-group input-expires-at">
<label>{{d-icon "far-clock"}}{{this.expiresAtLabel}}</label>
</div>
{{/if}}
</form>
</:body>
<:footer>
<DButton
@icon="link"
@label="user.invited.invite.save_invite"
@action={{this.saveInvite}}
class="btn-primary save-invite"
/>
<DButton
@icon="envelope"
@label={{if
this.invite.emailed
"user.invited.reinvite"
"user.invited.invite.send_invite_email"
}}
@action={{fn this.saveInvite true}}
@title={{unless
this.isEmail
"user.invited.invite.send_invite_email_instructions"
}}
@disabled={{not this.isEmail}}
class="btn-primary send-invite"
/>
</:footer>
</DModal>

View File

@ -1,209 +0,0 @@
import Component from "@ember/component";
import { action } from "@ember/object";
import { not, readOnly } from "@ember/object/computed";
import { extractError } from "discourse/lib/ajax-error";
import { getNativeContact } from "discourse/lib/pwa-utils";
import { sanitize } from "discourse/lib/text";
import { timeShortcuts } from "discourse/lib/time-shortcut";
import { emailValid, hostnameValid } from "discourse/lib/utilities";
import { bufferedProperty } from "discourse/mixins/buffered-content";
import Group from "discourse/models/group";
import Invite from "discourse/models/invite";
import discourseComputed from "discourse-common/utils/decorators";
import I18n from "discourse-i18n";
import { FORMAT } from "select-kit/components/future-date-input-selector";
export default class CreateInvite extends Component.extend(
bufferedProperty("invite")
) {
allGroups = null;
topics = null;
flashText = null;
flashClass = null;
flashLink = false;
inviteToTopic = false;
limitToEmail = false;
@readOnly("model.editing") editing;
@not("isEmail") isLink;
@discourseComputed("buffered.emailOrDomain")
isEmail(emailOrDomain) {
return emailValid(emailOrDomain?.trim());
}
@discourseComputed("buffered.emailOrDomain")
isDomain(emailOrDomain) {
return hostnameValid(emailOrDomain?.trim());
}
init() {
super.init();
Group.findAll().then((groups) => {
this.set("allGroups", groups.filterBy("automatic", false));
});
this.set("invite", this.model.invite || Invite.create());
this.set("topics", this.invite?.topics || this.model.topics || []);
this.buffered.setProperties({
max_redemptions_allowed: this.model.invite?.max_redemptions_allowed ?? 1,
expires_at:
this.model.invite?.expires_at ??
moment()
.add(this.siteSettings.invite_expiry_days, "days")
.format(FORMAT),
groupIds: this.model.groupIds ?? this.model.invite?.groupIds,
topicId: this.model.invite?.topicId,
topicTitle: this.model.invite?.topicTitle,
});
}
save(opts) {
const data = { ...this.buffered.buffer };
if (data.emailOrDomain) {
if (emailValid(data.emailOrDomain)) {
data.email = data.emailOrDomain?.trim();
} else if (hostnameValid(data.emailOrDomain)) {
data.domain = data.emailOrDomain?.trim();
}
delete data.emailOrDomain;
}
if (data.groupIds !== undefined) {
data.group_ids = data.groupIds.length > 0 ? data.groupIds : "";
delete data.groupIds;
}
if (data.topicId !== undefined) {
data.topic_id = data.topicId;
delete data.topicId;
delete data.topicTitle;
}
if (this.isLink) {
if (this.invite.email) {
data.email = data.custom_message = "";
}
} else if (this.isEmail) {
if (this.invite.max_redemptions_allowed > 1) {
data.max_redemptions_allowed = 1;
}
if (opts.sendEmail) {
data.send_email = true;
if (this.inviteToTopic) {
data.invite_to_topic = true;
}
} else {
data.skip_email = true;
}
}
return this.invite
.save(data)
.then(() => {
this.rollbackBuffer();
const invites = this.model?.invites;
if (invites && !invites.any((i) => i.id === this.invite.id)) {
invites.unshiftObject(this.invite);
}
if (this.isEmail && opts.sendEmail) {
this.closeModal();
} else {
this.setProperties({
flashText: sanitize(I18n.t("user.invited.invite.invite_saved")),
flashClass: "success",
flashLink: !this.editing,
});
}
})
.catch((e) =>
this.setProperties({
flashText: sanitize(extractError(e)),
flashClass: "error",
flashLink: false,
})
);
}
@discourseComputed(
"currentUser.staff",
"siteSettings.invite_link_max_redemptions_limit",
"siteSettings.invite_link_max_redemptions_limit_users"
)
maxRedemptionsAllowedLimit(staff, staffLimit, usersLimit) {
return staff ? staffLimit : usersLimit;
}
@discourseComputed("buffered.expires_at")
expiresAtLabel(expires_at) {
const expiresAt = moment(expires_at);
return expiresAt.isBefore()
? I18n.t("user.invited.invite.expired_at_time", {
time: expiresAt.format("LLL"),
})
: I18n.t("user.invited.invite.expires_in_time", {
time: moment.duration(expiresAt - moment()).humanize(),
});
}
@discourseComputed("currentUser.staff", "currentUser.groups")
canInviteToGroup(staff, groups) {
return staff || groups.any((g) => g.owner);
}
@discourseComputed("currentUser.staff")
canArriveAtTopic(staff) {
if (staff && !this.siteSettings.must_approve_users) {
return true;
}
return false;
}
@discourseComputed
timeShortcuts() {
const timezone = this.currentUser.user_option.timezone;
const shortcuts = timeShortcuts(timezone);
return [
shortcuts.laterToday(),
shortcuts.tomorrow(),
shortcuts.laterThisWeek(),
shortcuts.monday(),
shortcuts.twoWeeks(),
shortcuts.nextMonth(),
shortcuts.twoMonths(),
shortcuts.threeMonths(),
shortcuts.fourMonths(),
shortcuts.sixMonths(),
];
}
@action
copied() {
this.save({ sendEmail: false, copy: true });
}
@action
saveInvite(sendEmail) {
this.save({ sendEmail });
}
@action
searchContact() {
getNativeContact(this.capabilities, ["email"], false).then((result) => {
this.set("buffered.email", result[0].email[0]);
});
}
@action
onChangeTopic(topicId, topic) {
this.set("topics", [topic]);
this.set("buffered.topicId", topicId);
}
}

View File

@ -1,28 +1,29 @@
import { Promise } from "rsvp";
export function canNativeShare(caps) {
return (
(caps.isIOS || caps.isAndroid || caps.isWinphone) &&
window.location.protocol === "https:" &&
typeof window.navigator.share !== "undefined"
);
}
export function nativeShare(caps, data) {
return new Promise((resolve, reject) => {
if (!(caps.isIOS || caps.isAndroid || caps.isWinphone)) {
if (!canNativeShare(caps)) {
reject();
return;
}
if (
window.location.protocol === "https:" &&
typeof window.navigator.share !== "undefined"
) {
window.navigator
.share(data)
.then(resolve)
.catch((e) => {
if (e.name === "AbortError") {
// closing share panel do nothing
} else {
reject();
}
});
} else {
reject();
}
window.navigator
.share(data)
.then(resolve)
.catch((e) => {
if (e.name === "AbortError") {
// closing share panel do nothing
} else {
reject();
}
});
});
}

View File

@ -21,7 +21,7 @@
@icon="plus"
@action={{this.createInvite}}
@label="user.invited.create"
class="btn-default"
class="btn-default invite-button"
/>
{{#if this.canBulkInvite}}
{{#if this.siteSettings.allow_bulk_invite}}

View File

@ -1,290 +0,0 @@
import { click, fillIn, visit } from "@ember/test-helpers";
import { test } from "qunit";
import {
acceptance,
exists,
fakeTime,
loggedInUser,
queryAll,
} from "discourse/tests/helpers/qunit-helpers";
import I18n from "discourse-i18n";
acceptance("Invites - Create & Edit Invite Modal", function (needs) {
needs.user();
needs.pretender((server, helper) => {
const inviteData = {
id: 1,
invite_key: "52641ae8878790bc7b79916247cfe6ba",
link: "http://example.com/invites/52641ae8878790bc7b79916247cfe6ba",
max_redemptions_allowed: 1,
redemption_count: 0,
created_at: "2021-01-26T12:00:00.000Z",
updated_at: "2021-01-26T12:00:00.000Z",
expires_at: "2121-01-26T12:00:00.000Z",
expired: false,
topics: [],
groups: [],
};
server.post("/invites", () => helper.response(inviteData));
server.put("/invites/1", (request) => {
const data = helper.parsePostData(request.requestBody);
if (data.email === "error") {
return helper.response(422, {
errors: ["error isn't a valid email address."],
});
} else {
return helper.response(inviteData);
}
});
server.delete("/invites", () => {
return helper.response({});
});
});
test("basic functionality", async function (assert) {
await visit("/u/eviltrout/invited/pending");
await click(".user-invite-buttons .btn:first-child");
await assert.dom(".invite-to-groups").exists();
await assert.dom(".invite-to-topic").exists();
await assert.dom(".invite-expires-at").exists();
});
test("saving", async function (assert) {
await visit("/u/eviltrout/invited/pending");
await click(".user-invite-buttons .btn:first-child");
assert
.dom("table.user-invite-list tbody tr")
.exists({ count: 3 }, "is seeded with three rows");
await click(".btn-primary");
assert
.dom("table.user-invite-list tbody tr")
.exists({ count: 4 }, "gets added to the list");
});
test("copying saves invite", async function (assert) {
await visit("/u/eviltrout/invited/pending");
await click(".user-invite-buttons .btn:first-child");
await click(".save-invite");
assert.dom(".invite-link .btn").exists();
});
});
acceptance("Invites - Link Invites", function (needs) {
needs.user();
needs.pretender((server, helper) => {
const inviteData = {
id: 1,
invite_key: "52641ae8878790bc7b79916247cfe6ba",
link: "http://example.com/invites/52641ae8878790bc7b79916247cfe6ba",
max_redemptions_allowed: 1,
redemption_count: 0,
created_at: "2021-01-26T12:00:00.000Z",
updated_at: "2021-01-26T12:00:00.000Z",
expires_at: "2121-01-26T12:00:00.000Z",
expired: false,
topics: [],
groups: [],
};
server.post("/invites", () => helper.response(inviteData));
server.put("/invites/1", () => helper.response(inviteData));
server.delete("/invites", () => helper.response({}));
});
test("invite links", async function (assert) {
await visit("/u/eviltrout/invited/pending");
await click(".user-invite-buttons .btn:first-child");
assert.ok(exists("#invite-max-redemptions"), "shows max redemptions field");
});
});
acceptance("Invites - Email Invites", function (needs) {
let lastRequest;
needs.user();
needs.pretender((server, helper) => {
const inviteData = {
id: 1,
invite_key: "52641ae8878790bc7b79916247cfe6ba",
link: "http://example.com/invites/52641ae8878790bc7b79916247cfe6ba",
email: "test@example.com",
emailed: false,
custom_message: null,
created_at: "2021-01-26T12:00:00.000Z",
updated_at: "2021-01-26T12:00:00.000Z",
expires_at: "2121-01-26T12:00:00.000Z",
expired: false,
topics: [],
groups: [],
};
server.post("/invites", (request) => {
lastRequest = request;
return helper.response(inviteData);
});
server.put("/invites/1", (request) => {
lastRequest = request;
return helper.response(inviteData);
});
});
needs.hooks.beforeEach(() => {
lastRequest = null;
});
test("invite email", async function (assert) {
await visit("/u/eviltrout/invited/pending");
await click(".user-invite-buttons .btn:first-child");
assert.ok(exists("#invite-email"), "shows email field");
await fillIn("#invite-email", "test@example.com");
assert.ok(exists(".save-invite"), "shows save without email button");
await click(".save-invite");
assert.ok(
lastRequest.requestBody.includes("skip_email=true"),
"sends skip_email to server"
);
await fillIn("#invite-email", "test2@example.com ");
assert.ok(exists(".send-invite"), "shows save and send email button");
await click(".send-invite");
assert.ok(
lastRequest.requestBody.includes("send_email=true"),
"sends send_email to server"
);
});
});
acceptance(
"Invites - Create & Edit Invite Modal - timeframe choosing",
function (needs) {
let clock = null;
needs.user();
needs.pretender((server, helper) => {
const inviteData = {
id: 1,
invite_key: "52641ae8878790bc7b79916247cfe6ba",
link: "http://example.com/invites/52641ae8878790bc7b79916247cfe6ba",
max_redemptions_allowed: 1,
redemption_count: 0,
created_at: "2021-01-26T12:00:00.000Z",
updated_at: "2021-01-26T12:00:00.000Z",
expires_at: "2121-01-26T12:00:00.000Z",
expired: false,
topics: [],
groups: [],
};
server.post("/invites", () => helper.response(inviteData));
server.put("/invites/1", () => helper.response(inviteData));
});
needs.hooks.beforeEach(() => {
const timezone = loggedInUser().user_option.timezone;
clock = fakeTime("2100-05-03T08:00:00", timezone, true); // Monday morning
});
needs.hooks.afterEach(() => {
clock.restore();
});
test("shows correct timeframe options", async function (assert) {
await visit("/u/eviltrout/invited/pending");
await click(".user-invite-buttons .btn:first-child");
await click(".future-date-input-selector-header");
const options = Array.from(
queryAll(`ul.select-kit-collection li span.name`).map((_, x) =>
x.innerText.trim()
)
);
const expected = [
I18n.t("time_shortcut.later_today"),
I18n.t("time_shortcut.tomorrow"),
I18n.t("time_shortcut.later_this_week"),
I18n.t("time_shortcut.start_of_next_business_week_alt"),
I18n.t("time_shortcut.two_weeks"),
I18n.t("time_shortcut.next_month"),
I18n.t("time_shortcut.two_months"),
I18n.t("time_shortcut.three_months"),
I18n.t("time_shortcut.four_months"),
I18n.t("time_shortcut.six_months"),
I18n.t("time_shortcut.custom"),
];
assert.deepEqual(options, expected, "options are correct");
});
}
);
acceptance(
"Invites - Create Invite on Site with must_approve_users Setting",
function (needs) {
needs.user();
needs.settings({ must_approve_users: true });
test("hides `Arrive at Topic` field on sites with `must_approve_users`", async function (assert) {
await visit("/u/eviltrout/invited/pending");
await click(".user-invite-buttons .btn:first-child");
assert.dom(".invite-to-topic").doesNotExist();
});
}
);
acceptance(
"Invites - Populates Edit Invite Form with saved invite data",
function (needs) {
needs.user();
needs.pretender((server, helper) => {
server.get("/groups/search.json", () => {
return helper.response([
{
id: 41,
automatic: false,
name: "Macdonald",
},
{
id: 47, // must match group-fixtures.js because lookup is by ID
automatic: false,
name: "Discourse",
},
]);
});
server.post("/invites", () => helper.response({}));
});
test("shows correct saved data in form", async function (assert) {
await visit("/u/eviltrout/invited/pending");
await click(
".user-invite-list tbody tr:nth-child(3) .invite-actions .btn:first-child"
); // third invite edit button
assert.dom("#invite-max-redemptions").hasValue("10");
assert
.dom(".invite-to-topic .name")
.hasText("Welcome to Discourse! :wave:");
assert.dom(".invite-to-groups .formatted-selection").hasText("Macdonald");
assert.dom("#invite-email").hasValue("cat.com");
});
test("shows correct saved data in group invite form", async function (assert) {
await visit("/g/discourse");
await click(".group-members-invite");
assert.dom(".invite-to-groups .formatted-selection").hasText("Discourse");
await click(".save-invite");
});
}
);

View File

@ -127,6 +127,10 @@
animation-duration: 0s;
}
}
#modal-alert {
padding-left: 1.5rem;
}
}
//legacy

View File

@ -13,6 +13,16 @@
}
}
.link-share-container {
.invite-link {
flex: 1 0;
}
.mobile-view & {
flex-direction: column;
}
}
.link-share-actions {
display: flex;
flex-wrap: wrap;
@ -42,100 +52,3 @@
color: var(--facebook);
}
}
// topic invite modal
.create-invite-modal {
form {
margin: 0;
}
input {
margin-bottom: 0;
}
label {
margin-right: 0.5em;
.d-icon {
color: var(--primary-medium);
margin-right: 0.75em;
}
}
textarea {
margin-bottom: 0;
}
.input-group:not(:last-of-type) {
margin-bottom: 1em;
}
.input-group.input-expires-at,
.input-group.input-email,
.input-group.invite-max-redemptions {
input[type="text"] {
width: unset;
}
}
.existing-topic,
p {
// p is for "no topics found"
margin-left: 1.75em;
margin-top: 0.25em;
}
.input-group.input-email {
display: flex;
align-items: baseline;
label {
display: inline;
}
}
.invite-email-container {
flex: 1 1 auto;
#invite-email {
width: 100%;
}
}
.invite-max-redemptions {
label {
display: inline;
}
input {
width: 80px;
}
}
.invite-custom-message {
label {
margin-left: 1.75em;
}
}
.input-group {
textarea#invite-message,
&.invite-to-topic input[type="text"],
.group-chooser,
.topic-chooser,
.user-chooser,
.future-date-input-selector,
.future-date-input-date-picker,
.future-date-input-time-picker {
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

@ -36,10 +36,6 @@ html:not(.keyboard-visible.mobile-view) {
max-height: calc(var(--composer-vh, var(--1dvh)) * 85);
}
&__header {
padding: 0.5rem;
}
&__footer {
margin-top: auto;
}

View File

@ -1964,14 +1964,26 @@ en:
error: "There was an error generating Invite link"
invite:
new_title: "Create Invite"
edit_title: "Edit Invite"
new_title: "Invite members"
edit_title: "Edit invite"
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}"
create_link_to_invite: "Create a link that can be shared to instantly grant access to this site."
copy_link_and_share_it: "Copy the link below and share it to instantly grant access to this site."
copy_link: "Copy link"
link_copied: "Link copied!"
share_link: "Share link"
link_validity_MF: |
Link is valid for up to { user_count, plural,
one {# user}
other {# users}
} and expires in { duration_days, plural,
one {# day}
other {# days}
}.
edit_link_options: "Edit link options."
show_advanced: "Show Advanced Options"
hide_advanced: "Hide Advanced Options"
@ -1984,12 +1996,16 @@ en:
add_to_groups: "Add to groups"
invite_to_topic: "Arrive at topic"
expires_at: "Expire after"
custom_message: "Optional personal message"
expires_at: "Expire at"
expires_after: "Expire after"
custom_message: "Custom message"
custom_message_placeholder: "Add a personal note to your invitation"
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"
cancel: "Cancel"
create_link: "Create link"
invite_saved: "Invite saved."

View File

@ -0,0 +1,297 @@
# frozen_string_literal: true
describe "Creating Invites", type: :system do
fab!(:group)
fab!(:user) { Fabricate(:user, groups: [group]) }
fab!(:topic) { Fabricate(:post).topic }
let(:user_invited_pending_page) { PageObjects::Pages::UserInvitedPending.new }
let(:create_invite_modal) { PageObjects::Modals::CreateInvite.new }
let(:cdp) { PageObjects::CDP.new }
def open_invite_modal
find(".user-invite-buttons .btn", match: :first).click
end
def display_advanced_options
create_invite_modal.edit_options_link.click
end
before do
SiteSetting.invite_allowed_groups = "#{group.id}"
SiteSetting.invite_link_max_redemptions_limit_users = 7
SiteSetting.invite_link_max_redemptions_limit = 63
SiteSetting.invite_expiry_days = 3
sign_in(user)
end
before do
user_invited_pending_page.visit(user)
open_invite_modal
end
it "is possible to create an invite link without toggling the advanced options" do
cdp.allow_clipboard
create_invite_modal.save_button.click
create_invite_modal.copy_button.click
invite_link = create_invite_modal.invite_link_input.value
invite_key = invite_link.split("/").last
cdp.clipboard_has_text?(invite_link)
expect(create_invite_modal.link_limits_info_paragraph).to have_text(
"Link is valid for up to 7 users and expires in 3 days.",
)
create_invite_modal.close
expect(user_invited_pending_page.invites_list.size).to eq(1)
expect(user_invited_pending_page.latest_invite).to be_link_type(
key: invite_key,
redemption_count: 0,
max_redemption_count: 7,
)
expect(user_invited_pending_page.latest_invite.expiry_date).to be_within(2.minutes).of(
Time.zone.now + 3.days,
)
end
it "has the correct modal title when creating a new invite" do
expect(create_invite_modal.header).to have_text(I18n.t("js.user.invited.invite.new_title"))
end
it "hides the modal footer after creating an invite via simple mode" do
expect(create_invite_modal).to have_footer
create_invite_modal.save_button.click
expect(create_invite_modal).to have_no_footer
end
context "when editing an invite" do
before do
create_invite_modal.save_button.click
create_invite_modal.close
expect(user_invited_pending_page.invites_list.size).to eq(1)
user_invited_pending_page.latest_invite.edit_button.click
end
it "has the correct modal title" do
expect(create_invite_modal.header).to have_text(I18n.t("js.user.invited.invite.edit_title"))
end
it "displays the invite link and a copy button" do
expect(create_invite_modal).to have_copy_button
expect(create_invite_modal).to have_invite_link_input
end
end
context "with the advanced options" do
before { display_advanced_options }
it "is possible to populate all the fields" do
user.update!(admin: true)
page.refresh
open_invite_modal
display_advanced_options
create_invite_modal.form.field("restrictTo").fill_in("discourse.org")
create_invite_modal.form.field("maxRedemptions").fill_in("53")
create_invite_modal.form.field("expiresAfterDays").select(90)
create_invite_modal.choose_topic(topic)
create_invite_modal.choose_groups([group])
create_invite_modal.save_button.click
expect(create_invite_modal).to have_copy_button
invite_link = create_invite_modal.invite_link_input.value
invite_key = invite_link.split("/").last
create_invite_modal.close
expect(user_invited_pending_page.invites_list.size).to eq(1)
expect(user_invited_pending_page.latest_invite).to be_link_type(
key: invite_key,
redemption_count: 0,
max_redemption_count: 53,
)
expect(user_invited_pending_page.latest_invite).to have_group(group)
expect(user_invited_pending_page.latest_invite).to have_topic(topic)
expect(user_invited_pending_page.latest_invite.expiry_date).to be_within(2.minutes).of(
Time.zone.now + 90.days,
)
end
it "is possible to create an email invite" do
another_group = Fabricate(:group)
user.update!(admin: true)
page.refresh
open_invite_modal
display_advanced_options
create_invite_modal.form.field("restrictTo").fill_in("someone@discourse.org")
create_invite_modal.form.field("expiresAfterDays").select(1)
create_invite_modal.choose_topic(topic)
create_invite_modal.choose_groups([group, another_group])
create_invite_modal
.form
.field("customMessage")
.fill_in("Hello someone, this is a test invite")
create_invite_modal.save_button.click
expect(create_invite_modal).to have_copy_button
invite_link = create_invite_modal.invite_link_input.value
invite_key = invite_link.split("/").last
create_invite_modal.close
expect(user_invited_pending_page.invites_list.size).to eq(1)
expect(user_invited_pending_page.latest_invite).to be_email_type("someone@discourse.org")
expect(user_invited_pending_page.latest_invite).to have_group(group)
expect(user_invited_pending_page.latest_invite).to have_group(another_group)
expect(user_invited_pending_page.latest_invite).to have_topic(topic)
expect(user_invited_pending_page.latest_invite.expiry_date).to be_within(2.minutes).of(
Time.zone.now + 1.day,
)
end
it "adds the invite_expiry_days site setting to the list of options for the expiresAfterDays field" do
options =
create_invite_modal
.form
.field("expiresAfterDays")
.component
.all(".form-kit__control-option")
.map(&:text)
expect(options).to eq(["1 day", "3 days", "7 days", "30 days", "90 days", "Never"])
SiteSetting.invite_expiry_days = 90
page.refresh
open_invite_modal
display_advanced_options
options =
create_invite_modal
.form
.field("expiresAfterDays")
.component
.all(".form-kit__control-option")
.map(&:text)
expect(options).to eq(["1 day", "7 days", "30 days", "90 days", "Never"])
end
it "uses the invite_link_max_redemptions_limit_users setting as the default value for the maxRedemptions field if the setting is lower than 10" do
expect(create_invite_modal.form.field("maxRedemptions").value).to eq("7")
SiteSetting.invite_link_max_redemptions_limit_users = 11
page.refresh
open_invite_modal
display_advanced_options
expect(create_invite_modal.form.field("maxRedemptions").value).to eq("10")
end
it "uses the invite_link_max_redemptions_limit setting as the default value for the maxRedemptions field for staff users if the setting is lower than 100" do
user.update!(admin: true)
page.refresh
open_invite_modal
display_advanced_options
expect(create_invite_modal.form.field("maxRedemptions").value).to eq("63")
SiteSetting.invite_link_max_redemptions_limit = 108
page.refresh
open_invite_modal
display_advanced_options
expect(create_invite_modal.form.field("maxRedemptions").value).to eq("100")
end
it "shows the inviteToGroups field for a normal user if they're owner on at least 1 group" do
expect(create_invite_modal.form).to have_no_field_with_name("inviteToGroups")
group.add_owner(user)
page.refresh
open_invite_modal
display_advanced_options
expect(create_invite_modal.form).to have_field_with_name("inviteToGroups")
end
it "shows the inviteToGroups field for admins" do
user.update!(admin: true)
page.refresh
open_invite_modal
display_advanced_options
expect(create_invite_modal.form).to have_field_with_name("inviteToGroups")
end
it "doesn't show the inviteToTopic field to normal users" do
SiteSetting.must_approve_users = false
page.refresh
open_invite_modal
display_advanced_options
expect(create_invite_modal.form).to have_no_field_with_name("inviteToTopic")
end
it "shows the inviteToTopic field to admins if the must_approve_users setting is false" do
user.update!(admin: true)
SiteSetting.must_approve_users = false
page.refresh
open_invite_modal
display_advanced_options
expect(create_invite_modal.form).to have_field_with_name("inviteToTopic")
end
it "doesn't show the inviteToTopic field to admins if the must_approve_users setting is true" do
user.update!(admin: true)
SiteSetting.must_approve_users = true
page.refresh
open_invite_modal
display_advanced_options
expect(create_invite_modal.form).to have_no_field_with_name("inviteToTopic")
end
it "replaces the expiresAfterDays field with expiresAt with date and time controls after creating the invite" do
create_invite_modal.form.field("expiresAfterDays").select(1)
create_invite_modal.save_button.click
now = Time.zone.now
expect(create_invite_modal.form).to have_no_field_with_name("expiresAfterDays")
expect(create_invite_modal.form).to have_field_with_name("expiresAt")
expires_at_field = create_invite_modal.form.field("expiresAt").component
date = expires_at_field.find(".date-picker").value
time = expires_at_field.find(".time-input").value
expire_date = Time.parse("#{date} #{time}:#{now.strftime("%S")}").utc
expect(expire_date).to be_within_one_minute_of(now + 1.day)
end
context "when an email is given to the restrictTo field" do
it "shows the customMessage field and hides the maxRedemptions field" do
expect(create_invite_modal.form).to have_no_field_with_name("customMessage")
expect(create_invite_modal.form).to have_field_with_name("maxRedemptions")
create_invite_modal.form.field("restrictTo").fill_in("discourse@cdck.org")
expect(create_invite_modal.form).to have_field_with_name("customMessage")
expect(create_invite_modal.form).to have_no_field_with_name("maxRedemptions")
end
end
end
end

View File

@ -114,7 +114,12 @@ module PageObjects
picker.search(value)
picker.select_row_by_value(value)
when "select"
component.find(".form-kit__control-option[value='#{value}']").click
selector = component.find(".form-kit__control-select")
selector.find(".form-kit__control-option[value='#{value}']").select_option
selector.execute_script(<<~JS, selector)
var selector = arguments[0];
selector.dispatchEvent(new Event("input", { bubbles: true, cancelable: true }));
JS
when "menu"
trigger = component.find(".fk-d-menu__trigger.form-kit__control-menu")
trigger.click
@ -193,6 +198,14 @@ module PageObjects
end
end
def has_field_with_name?(name)
has_css?(".form-kit__field[data-name='#{name}']")
end
def has_no_field_with_name?(name)
has_no_css?(".form-kit__field[data-name='#{name}']")
end
def container(name)
within component do
FormKitContainer.new(find(".form-kit__container[data-name='#{name}']"))

View File

@ -8,6 +8,10 @@ module PageObjects
BODY_SELECTOR = ""
def header
find(".d-modal__header")
end
def body
find(".d-modal__body#{BODY_SELECTOR}")
end
@ -16,6 +20,14 @@ module PageObjects
find(".d-modal__footer")
end
def has_footer?
has_css?(".d-modal__footer")
end
def has_no_footer?
has_no_css?(".d-modal__footer")
end
def close
find(".modal-close").click
end

View File

@ -0,0 +1,57 @@
# frozen_string_literal: true
module PageObjects
module Modals
class CreateInvite < PageObjects::Modals::Base
def modal
find(".create-invite-modal")
end
def edit_options_link
within(modal) { find(".edit-link-options") }
end
def save_button
within(modal) { find(".save-invite") }
end
def copy_button
within(modal) { find(".copy-button") }
end
def has_copy_button?
within(modal) { has_css?(".copy-button") }
end
def has_invite_link_input?
within(modal) { has_css?("input.invite-link") }
end
def invite_link_input
within(modal) { find("input.invite-link") }
end
def link_limits_info_paragraph
within(modal) { find("p.link-limits-info") }
end
def form
PageObjects::Components::FormKit.new(".create-invite-modal .form-kit")
end
def choose_topic(topic)
topic_picker = PageObjects::Components::SelectKit.new(".topic-chooser")
topic_picker.expand
topic_picker.search(topic.id)
topic_picker.select_row_by_index(0)
end
def choose_groups(groups)
group_picker = PageObjects::Components::SelectKit.new(".group-chooser")
group_picker.expand
groups.each { |group| group_picker.select_row_by_value(group.id) }
group_picker.collapse
end
end
end
end

View File

@ -0,0 +1,73 @@
# frozen_string_literal: true
module PageObjects
module Pages
class UserInvitedPending < PageObjects::Pages::Base
class Invite
attr_reader :tr_element
def initialize(tr_element)
@tr_element = tr_element
end
def link_type?(key: nil, redemption_count: nil, max_redemption_count: nil)
if key && redemption_count && max_redemption_count
invite_type_col.has_text?(
I18n.t(
"js.user.invited.invited_via_link",
key: "#{key[0...4]}...",
count: redemption_count,
max: max_redemption_count,
),
)
else
invite_type_col.has_css?(".d-icon-link")
end
end
def email_type?(email)
invite_type_col.has_text?(email) && invite_type_col.has_css?(".d-icon-envelope")
end
def has_group?(group)
invite_type_col.has_css?(".invite-extra", text: group.name)
end
def has_topic?(topic)
invite_type_col.has_css?(".invite-extra", text: topic.title)
end
def edit_button
tr_element.find(".invite-actions .btn-default")
end
def expiry_date
Time.parse(tr_element.find(".invite-expires-at").text).utc
end
private
def invite_type_col
tr_element.find(".invite-type")
end
end
def visit(user)
url = "/u/#{user.username_lower}/invited/pending"
page.visit(url)
end
def invite_button
find("#user-content .invite-button")
end
def invites_list
all("#user-content .user-invite-list tbody tr").map { |row| Invite.new(row) }
end
def latest_invite
Invite.new(find("#user-content .user-invite-list tbody tr:first-of-type"))
end
end
end
end