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;
+ }
+
+
+
+ <:belowHeader>
+ {{#if (or this.flashText @model.editing)}}
+
+ {{#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}}
+
+ {{/if}}
+
+ <:body>
+ {{#if this.simpleMode}}
+ {{#if this.inviteCreated}}
+ {{#unless this.site.mobileView}}
+
+ {{i18n "user.invited.invite.copy_link_and_share_it"}}
+
+ {{/unless}}
+
+
+
+ {{else}}
+
+ {{i18n "user.invited.invite.create_link_to_invite"}}
+
+ {{/if}}
+
+ {{this.linkValidityMessageFormat}}
+ {{i18n "user.invited.invite.edit_link_options"}}
+
+ {{else}}
+
+
+
+
+ {{#unless (emailValid transientData.restrictTo)}}
+
+
+
+ {{/unless}}
+
+ {{#if this.inviteCreated}}
+
+
+
+
+
+ {{else}}
+
+
+ {{#each this.expireAfterOptions as |option|}}
+ {{option.text}}
+ {{/each}}
+
+
+ {{/if}}
+
+ {{#if this.canArriveAtTopic}}
+
+
+
+
+
+ {{/if}}
+
+ {{#if this.canInviteToGroup}}
+
+
+
+
+
+ {{/if}}
+
+ {{#if (emailValid transientData.restrictTo)}}
+
+
+
+ {{/if}}
+
+ {{/if}}
+
+ <:footer>
+ {{#if this.simpleMode}}
+
+ {{else}}
+
+ {{/if}}
+
+
+
+
+}
+
+const InviteModalAlert =
+
+;
+
+class ShareOrCopyInviteLink extends Component {
+ @service capabilities;
+
+ @action
+ async nativeShare() {
+ await nativeShare(this.capabilities, { url: this.args.invite.link });
+ }
+
+
+
+ {{#if (canNativeShare this.capabilities)}}
+
+ {{else}}
+
+ {{/if}}
+
+}
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 this.flashLink}}
-
- {{else}}
- {{html-safe this.flashText}}
- {{/if}}
-
- {{/if}}
-
- <:body>
-
-
- <:footer>
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/app/components/modal/create-invite.js b/app/assets/javascripts/discourse/app/components/modal/create-invite.js
deleted file mode 100644
index a0201717f74..00000000000
--- a/app/assets/javascripts/discourse/app/components/modal/create-invite.js
+++ /dev/null
@@ -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);
- }
-}
diff --git a/app/assets/javascripts/discourse/app/lib/pwa-utils.js b/app/assets/javascripts/discourse/app/lib/pwa-utils.js
index 0bcc46434a0..247f6552038 100644
--- a/app/assets/javascripts/discourse/app/lib/pwa-utils.js
+++ b/app/assets/javascripts/discourse/app/lib/pwa-utils.js
@@ -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();
+ }
+ });
});
}
diff --git a/app/assets/javascripts/discourse/app/templates/user-invited-show.hbs b/app/assets/javascripts/discourse/app/templates/user-invited-show.hbs
index 1c0413f61d4..16ee9709abd 100644
--- a/app/assets/javascripts/discourse/app/templates/user-invited-show.hbs
+++ b/app/assets/javascripts/discourse/app/templates/user-invited-show.hbs
@@ -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}}
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
deleted file mode 100644
index dbbe470b357..00000000000
--- a/app/assets/javascripts/discourse/tests/acceptance/create-invite-modal-test.js
+++ /dev/null
@@ -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");
- });
- }
-);
diff --git a/app/assets/stylesheets/common/base/modal.scss b/app/assets/stylesheets/common/base/modal.scss
index 9edcaa34f27..a83f5731184 100644
--- a/app/assets/stylesheets/common/base/modal.scss
+++ b/app/assets/stylesheets/common/base/modal.scss
@@ -127,6 +127,10 @@
animation-duration: 0s;
}
}
+
+ #modal-alert {
+ padding-left: 1.5rem;
+ }
}
//legacy
diff --git a/app/assets/stylesheets/common/base/share_link.scss b/app/assets/stylesheets/common/base/share_link.scss
index 33103922bf4..a2ada39ecc2 100644
--- a/app/assets/stylesheets/common/base/share_link.scss
+++ b/app/assets/stylesheets/common/base/share_link.scss
@@ -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;
- }
- }
- }
-}
diff --git a/app/assets/stylesheets/mobile/modal.scss b/app/assets/stylesheets/mobile/modal.scss
index 597d7a10415..8ca1ed709b2 100644
--- a/app/assets/stylesheets/mobile/modal.scss
+++ b/app/assets/stylesheets/mobile/modal.scss
@@ -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;
}
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 5e0a7388631..dc046bb6c28 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -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."
diff --git a/spec/system/create_invite_spec.rb b/spec/system/create_invite_spec.rb
new file mode 100644
index 00000000000..06fe2138ace
--- /dev/null
+++ b/spec/system/create_invite_spec.rb
@@ -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
diff --git a/spec/system/page_objects/components/form_kit.rb b/spec/system/page_objects/components/form_kit.rb
index 93329bc8a00..9ece6eec4f5 100644
--- a/spec/system/page_objects/components/form_kit.rb
+++ b/spec/system/page_objects/components/form_kit.rb
@@ -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}']"))
diff --git a/spec/system/page_objects/modals/base.rb b/spec/system/page_objects/modals/base.rb
index a676e7a87d2..963800bf05e 100644
--- a/spec/system/page_objects/modals/base.rb
+++ b/spec/system/page_objects/modals/base.rb
@@ -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
diff --git a/spec/system/page_objects/modals/create_invite.rb b/spec/system/page_objects/modals/create_invite.rb
new file mode 100644
index 00000000000..b560bdb2571
--- /dev/null
+++ b/spec/system/page_objects/modals/create_invite.rb
@@ -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
diff --git a/spec/system/page_objects/pages/user_invited_pending.rb b/spec/system/page_objects/pages/user_invited_pending.rb
new file mode 100644
index 00000000000..92c517e9fd5
--- /dev/null
+++ b/spec/system/page_objects/pages/user_invited_pending.rb
@@ -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