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
20 changed files with 1042 additions and 849 deletions

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