diff --git a/app/actions/remote/team.test.ts b/app/actions/remote/team.test.ts index 89770a117..17411fca2 100644 --- a/app/actions/remote/team.test.ts +++ b/app/actions/remote/team.test.ts @@ -14,6 +14,7 @@ import { addUserToTeam, addUsersToTeam, sendEmailInvitesToTeam, + sendGuestEmailInvitesToTeam, fetchMyTeams, fetchMyTeam, fetchTeamById, @@ -83,6 +84,7 @@ const mockClient = { addToTeam: jest.fn((id: string, userId: string) => ({id: userId + '-' + id, user_id: userId, team_id: id, roles: ''})), addUsersToTeamGracefully: jest.fn((id: string, userIds: string[]) => (userIds.map((userId) => ({member: {id: userId + '-' + id, user_id: userId, team_id: id, roles: ''}, error: undefined, user_id: userId})))), sendEmailInvitesToTeamGracefully: jest.fn((id: string, emails: string[]) => (emails.map((email) => ({email, error: undefined})))), + sendGuestEmailInvitesToTeamGracefully: jest.fn(() => Promise.resolve([{email: 'guest1@example.com', error: {message: '', status_code: 0}}])), getRolesByNames: jest.fn((roles: string[]) => roles.map((r) => ({id: r, name: r} as Role))), getMyTeams: jest.fn(() => ([{id: teamId, name: 'team1'}])), getMyTeamMembers: jest.fn(() => ([{id: 'userid1-' + teamId, user_id: 'userid1', team_id: teamId, roles: ''}])), @@ -223,6 +225,88 @@ describe('teamMember', () => { }); }); +describe('sendGuestEmailInvitesToTeam', () => { + const emails = ['guest1@example.com', 'guest2@example.com']; + const channels = ['channel-id-1', 'channel-id-2']; + const message = 'Welcome to the team!'; + + const throwFunc = () => { + throw Error('error'); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should send guest email invites successfully with all parameters', async () => { + const mockMembers: TeamInviteWithError[] = [ + {email: 'guest1@example.com', error: {message: '', status_code: 0}}, + {email: 'guest2@example.com', error: {message: '', status_code: 0}}, + ]; + mockClient.sendGuestEmailInvitesToTeamGracefully.mockResolvedValueOnce(mockMembers); + + const result = await sendGuestEmailInvitesToTeam(serverUrl, teamId, emails, channels, message); + + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.members).toEqual(mockMembers); + expect(mockClient.sendGuestEmailInvitesToTeamGracefully).toHaveBeenCalledWith(teamId, emails, channels, message, false); + expect(mockClient.sendGuestEmailInvitesToTeamGracefully).toHaveBeenCalledTimes(1); + }); + + it('should send guest email invites successfully without message parameter', async () => { + const mockMembers: TeamInviteWithError[] = [ + {email: 'guest1@example.com', error: {message: '', status_code: 0}}, + ]; + mockClient.sendGuestEmailInvitesToTeamGracefully.mockResolvedValueOnce(mockMembers); + + const result = await sendGuestEmailInvitesToTeam(serverUrl, teamId, ['guest1@example.com'], channels); + + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.members).toEqual(mockMembers); + expect(mockClient.sendGuestEmailInvitesToTeamGracefully).toHaveBeenCalledWith(teamId, ['guest1@example.com'], channels, '', false); + expect(mockClient.sendGuestEmailInvitesToTeamGracefully).toHaveBeenCalledTimes(1); + }); + + it('should handle client error', async () => { + const clientError = new Error('Client error'); + mockClient.sendGuestEmailInvitesToTeamGracefully.mockRejectedValueOnce(clientError); + + const result = await sendGuestEmailInvitesToTeam(serverUrl, teamId, emails, channels, message); + + expect(result).toBeDefined(); + expect(result.error).toBeDefined(); + expect(result.members).toEqual([]); + expect(mockClient.sendGuestEmailInvitesToTeamGracefully).toHaveBeenCalledWith(teamId, emails, channels, message, false); + }); + + it('should handle network manager error', async () => { + jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc); + + const result = await sendGuestEmailInvitesToTeam(serverUrl, teamId, emails, channels, message); + + expect(result).toBeDefined(); + expect(result.error).toBeDefined(); + expect(result.members).toEqual([]); + }); + + it('should send guest email invites with guest magic link', async () => { + const mockMembers: TeamInviteWithError[] = [ + {email: 'guest1@example.com', error: {message: '', status_code: 0}}, + ]; + mockClient.sendGuestEmailInvitesToTeamGracefully.mockResolvedValueOnce(mockMembers); + + const result = await sendGuestEmailInvitesToTeam(serverUrl, teamId, emails, channels, message, true); + + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.members).toEqual(mockMembers); + expect(mockClient.sendGuestEmailInvitesToTeamGracefully).toHaveBeenCalledWith(teamId, emails, channels, message, true); + expect(mockClient.sendGuestEmailInvitesToTeamGracefully).toHaveBeenCalledTimes(1); + }); +}); + describe('teams', () => { it('fetchMyTeams - handle not found database', async () => { const result = await fetchMyTeams('foo') as {error: unknown}; diff --git a/app/actions/remote/team.ts b/app/actions/remote/team.ts index f79e3869c..478299bb9 100644 --- a/app/actions/remote/team.ts +++ b/app/actions/remote/team.ts @@ -109,7 +109,7 @@ export async function addUserToTeam(serverUrl: string, teamId: string, userId: s } } -export async function addUsersToTeam(serverUrl: string, teamId: string, userIds: string[], fetchOnly = false) { +export async function addUsersToTeam(serverUrl: string, teamId: string, userIds: string[], fetchOnly = false): Promise<{members: TeamMemberWithError[]; error?: unknown}> { try { const client = NetworkManager.getClient(serverUrl); const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); @@ -142,20 +142,33 @@ export async function addUsersToTeam(serverUrl: string, teamId: string, userIds: } forceLogoutIfNecessary(serverUrl, error); - return {error}; + return {members: [], error}; } } -export async function sendEmailInvitesToTeam(serverUrl: string, teamId: string, emails: string[]) { +export async function sendEmailInvitesToTeam(serverUrl: string, teamId: string, emails: string[]): Promise<{members: TeamInviteWithError[]; error?: unknown}> { try { const client = NetworkManager.getClient(serverUrl); const members = await client.sendEmailInvitesToTeamGracefully(teamId, emails); - return {members}; + return {members, error: undefined}; } catch (error) { logDebug('error on sendEmailInvitesToTeam', getFullErrorMessage(error)); forceLogoutIfNecessary(serverUrl, error); - return {error}; + return {members: [], error}; + } +} + +export async function sendGuestEmailInvitesToTeam(serverUrl: string, teamId: string, emails: string[], channels: string[], message = '', guestMagicLink = false): Promise<{members: TeamInviteWithError[]; error?: unknown}> { + try { + const client = NetworkManager.getClient(serverUrl); + const members = await client.sendGuestEmailInvitesToTeamGracefully(teamId, emails, channels, message, guestMagicLink); + + return {members, error: undefined}; + } catch (error) { + logDebug('error on sendGuestEmailInvitesToTeam', getFullErrorMessage(error)); + forceLogoutIfNecessary(serverUrl, error); + return {members: [], error}; } } @@ -483,7 +496,7 @@ export async function handleKickFromTeam(serverUrl: string, teamId: string) { } } -export async function getTeamMembersByIds(serverUrl: string, teamId: string, userIds: string[], fetchOnly?: boolean) { +export async function getTeamMembersByIds(serverUrl: string, teamId: string, userIds: string[], fetchOnly?: boolean): Promise<{members: TeamMembership[]; error?: unknown}> { try { const client = NetworkManager.getClient(serverUrl); const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); @@ -505,7 +518,7 @@ export async function getTeamMembersByIds(serverUrl: string, teamId: string, use } catch (error) { logDebug('error on getTeamMembersByIds', getFullErrorMessage(error)); forceLogoutIfNecessary(serverUrl, error); - return {error}; + return {members: [], error}; } } diff --git a/app/client/rest/teams.test.ts b/app/client/rest/teams.test.ts index e1c1da09f..11c5428b2 100644 --- a/app/client/rest/teams.test.ts +++ b/app/client/rest/teams.test.ts @@ -194,6 +194,78 @@ describe('ClientTeams', () => { expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); }); + describe('sendGuestEmailInvitesToTeamGracefully', () => { + it('should send guest email invites with all params', async () => { + const teamId = 'team1'; + const emails = ['guest1@example.com', 'guest2@example.com']; + const channels = ['channel1', 'channel2']; + const message = 'Welcome to the team!'; + const params = {graceful: true}; + const expectedUrl = `${client.getTeamRoute(teamId)}/invite-guests/email${buildQueryString(params)}`; + const expectedOptions = {method: 'post', body: {message, emails, channels}}; + const mockResponse: TeamInviteWithError[] = [ + {email: 'guest1@example.com', error: {message: '', status_code: 0}}, + {email: 'guest2@example.com', error: {message: '', status_code: 0}}, + ]; + + jest.mocked(client.doFetch).mockResolvedValueOnce(mockResponse as any); + + const result = await client.sendGuestEmailInvitesToTeamGracefully(teamId, emails, channels, message); + + expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); + expect(result).toEqual(mockResponse); + }); + + it('should send guest email invites with default message', async () => { + const teamId = 'team1'; + const emails = ['guest1@example.com']; + const channels = ['channel1']; + const expectedUrl = `${client.getTeamRoute(teamId)}/invite-guests/email${buildQueryString({graceful: true})}`; + const expectedOptions = {method: 'post', body: {message: '', emails, channels}}; + const mockResponse: TeamInviteWithError[] = [ + {email: 'guest1@example.com', error: {message: '', status_code: 0}}, + ]; + + jest.mocked(client.doFetch).mockResolvedValueOnce(mockResponse as any); + + const result = await client.sendGuestEmailInvitesToTeamGracefully(teamId, emails, channels); + + expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); + expect(result).toEqual(mockResponse); + }); + + it('should handle error when sending guest email invites', async () => { + const teamId = 'team1'; + const emails = ['guest1@example.com']; + const channels = ['channel1']; + const message = 'Test message'; + + jest.mocked(client.doFetch).mockRejectedValueOnce(new Error('Network error')); + + await expect(client.sendGuestEmailInvitesToTeamGracefully(teamId, emails, channels, message)).rejects.toThrow('Network error'); + }); + + it('should send guest email invites with guest magic link', async () => { + const teamId = 'team1'; + const emails = ['guest1@example.com']; + const channels = ['channel1']; + const message = 'Test message'; + const guestMagicLink = true; + const expectedUrl = `${client.getTeamRoute(teamId)}/invite-guests/email${buildQueryString({graceful: true, guest_magic_link: true})}`; + const expectedOptions = {method: 'post', body: {message, emails, channels}}; + const mockResponse: TeamInviteWithError[] = [ + {email: 'guest1@example.com', error: {message: '', status_code: 0}}, + ]; + + jest.mocked(client.doFetch).mockResolvedValueOnce(mockResponse as any); + + const result = await client.sendGuestEmailInvitesToTeamGracefully(teamId, emails, channels, message, guestMagicLink); + + expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); + expect(result).toEqual(mockResponse); + }); + }); + test('joinTeam', async () => { const inviteId = 'invite1'; const query = buildQueryString({invite_id: inviteId}); diff --git a/app/client/rest/teams.ts b/app/client/rest/teams.ts index f290124d5..e52407ff6 100644 --- a/app/client/rest/teams.ts +++ b/app/client/rest/teams.ts @@ -6,6 +6,7 @@ import {buildQueryString} from '@utils/helpers'; import {PER_PAGE_DEFAULT} from './constants'; import type ClientBase from './base'; +import type {FirstArgument} from '@typings/utils/utils'; export interface ClientTeamsMix { createTeam: (team: Team) => Promise; @@ -24,6 +25,7 @@ export interface ClientTeamsMix { addToTeam: (teamId: string, userId: string) => Promise; addUsersToTeamGracefully: (teamId: string, userIds: string[]) => Promise; sendEmailInvitesToTeamGracefully: (teamId: string, emails: string[]) => Promise; + sendGuestEmailInvitesToTeamGracefully: (teamId: string, emails: string[], channels: string[], message?: string, magicLink?: boolean) => Promise; joinTeam: (inviteId: string) => Promise; removeFromTeam: (teamId: string, userId: string) => Promise; getTeamStats: (teamId: string) => Promise; @@ -147,6 +149,19 @@ const ClientTeams = >(superclass: TBase) = ); }; + sendGuestEmailInvitesToTeamGracefully = (teamId: string, emails: string[], channels: string[], message = '', guestMagicLink = false) => { + const params: FirstArgument = { + graceful: true, + }; + if (guestMagicLink) { + params.guest_magic_link = true; + } + return this.doFetch( + `${this.getTeamRoute(teamId)}/invite-guests/email${buildQueryString(params)}`, + {method: 'post', body: {message, emails, channels}}, + ); + }; + joinTeam = async (inviteId: string) => { const query = buildQueryString({invite_id: inviteId}); return this.doFetch( @@ -170,7 +185,7 @@ const ClientTeams = >(superclass: TBase) = }; getTeamIconUrl = (teamId: string, lastTeamIconUpdate: number) => { - const params: any = {}; + const params: FirstArgument = {}; if (lastTeamIconUpdate) { params._ = lastTeamIconUpdate; } diff --git a/app/components/option_item/index.tsx b/app/components/option_item/index.tsx index fcc447754..644bc0b73 100644 --- a/app/components/option_item/index.tsx +++ b/app/components/option_item/index.tsx @@ -117,6 +117,7 @@ export type OptionItemProps = { icon?: string; iconColor?: string; info?: string | UserChipData; + isInfoDestructive?: boolean; inline?: boolean; label: string; onRemove?: () => void; @@ -138,6 +139,7 @@ const OptionItem = ({ icon, iconColor, info, + isInfoDestructive = false, inline = false, label, onRemove, @@ -274,7 +276,7 @@ const OptionItem = ({ } else if (info) { infoComponent = ( diff --git a/app/components/option_item/option_item.test.tsx b/app/components/option_item/option_item.test.tsx index 4cf8ebb32..94cd6e35b 100644 --- a/app/components/option_item/option_item.test.tsx +++ b/app/components/option_item/option_item.test.tsx @@ -375,4 +375,16 @@ describe('OptionItem', () => { }); expect(props.action).not.toHaveBeenCalled(); }); + + it('should show destructive info styling when isInfoDestructive is true', () => { + const props = getBaseProps(); + props.info = 'Test info'; + props.isInfoDestructive = true; + props.destructive = false; + + const {getByText} = renderWithIntlAndTheme(); + + const info = getByText('Test info'); + expect(info).toHaveStyle({color: Preferences.THEMES.denim.dndIndicator}); + }); }); diff --git a/app/screens/invite/actions.test.ts b/app/screens/invite/actions.test.ts new file mode 100644 index 000000000..a0be003ca --- /dev/null +++ b/app/screens/invite/actions.test.ts @@ -0,0 +1,900 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/* eslint-disable max-lines */ + +import {createIntl} from 'react-intl'; + +import {addMembersToChannel} from '@actions/remote/channel'; +import {addUsersToTeam, getTeamMembersByIds, sendEmailInvitesToTeam, sendGuestEmailInvitesToTeam} from '@actions/remote/team'; +import {ServerErrors} from '@constants'; +import {DEFAULT_LOCALE, getTranslations} from '@i18n'; +import TestHelper from '@test/test_helper'; + +import {sendGuestInvites, sendMembersInvites} from './actions'; + +jest.mock('@actions/remote/channel'); +jest.mock('@actions/remote/team'); + +const mockAddMembersToChannel = jest.mocked(addMembersToChannel); +const mockAddUsersToTeam = jest.mocked(addUsersToTeam); +const mockGetTeamMembersByIds = jest.mocked(getTeamMembersByIds); +const mockSendEmailInvitesToTeam = jest.mocked(sendEmailInvitesToTeam); +const mockSendGuestEmailInvitesToTeam = jest.mocked(sendGuestEmailInvitesToTeam); + +const translations = getTranslations(DEFAULT_LOCALE); +const intl = createIntl({locale: DEFAULT_LOCALE, messages: translations}); + +describe('actions', () => { + const serverUrl = 'https://test.server.com'; + const teamId = 'team-1'; + const teamDisplayName = 'Test Team'; + const formatMessage = intl.formatMessage; + + describe('sendMembersInvites', () => { + it('should successfully invite users to team', async () => { + const userId1 = 'user-1'; + const userId2 = 'user-2'; + const user1 = TestHelper.fakeUser({id: userId1, roles: 'system_user'}); + const user2 = TestHelper.fakeUser({id: userId2, roles: 'system_user'}); + const selectedIds = { + [userId1]: user1, + [userId2]: user2, + }; + + mockGetTeamMembersByIds.mockResolvedValue({ + members: [], + error: undefined, + }); + + mockAddUsersToTeam.mockResolvedValue({ + members: [ + {member: TestHelper.fakeTeamMember(userId1, 'team-1'), user_id: userId1, error: undefined}, + {member: TestHelper.fakeTeamMember(userId2, 'team-1'), user_id: userId2, error: undefined}, + ], + error: undefined, + }); + + const result = await sendMembersInvites( + serverUrl, + teamId, + selectedIds, + false, + teamDisplayName, + formatMessage, + ); + + expect(result.error).toBe(false); + expect(result.sent).toHaveLength(2); + expect(result.notSent).toHaveLength(0); + expect(result.sent[0].userId).toBe(userId1); + expect(result.sent[1].userId).toBe(userId2); + expect(mockGetTeamMembersByIds).toHaveBeenCalledWith(serverUrl, teamId, [userId1, userId2]); + expect(mockAddUsersToTeam).toHaveBeenCalledWith(serverUrl, teamId, [userId1, userId2]); + }); + + it('should successfully invite users via email', async () => { + const email1 = 'user1@example.com'; + const email2 = 'user2@example.com'; + const selectedIds = { + [email1]: email1, + [email2]: email2, + }; + + mockSendEmailInvitesToTeam.mockResolvedValue({ + members: [ + {email: email1, error: undefined}, + {email: email2, error: undefined}, + ], + error: undefined, + }); + + const result = await sendMembersInvites( + serverUrl, + teamId, + selectedIds, + false, + teamDisplayName, + formatMessage, + ); + + expect(result.error).toBe(false); + expect(result.sent).toHaveLength(2); + expect(result.notSent).toHaveLength(0); + expect(result.sent[0].userId).toBe(email1); + expect(result.sent[1].userId).toBe(email2); + expect(mockSendEmailInvitesToTeam).toHaveBeenCalledWith(serverUrl, teamId, [email1, email2]); + }); + + it('should handle mixed users and emails', async () => { + const userId = 'user-1'; + const email = 'user@example.com'; + const user = TestHelper.fakeUser({id: userId, roles: 'system_user'}); + const selectedIds = { + [userId]: user, + [email]: email, + }; + + mockGetTeamMembersByIds.mockResolvedValue({ + members: [], + error: undefined, + }); + + mockAddUsersToTeam.mockResolvedValue({ + members: [{member: {...TestHelper.fakeTeamMember(userId, 'team-1')}, user_id: userId, error: undefined}], + error: undefined, + }); + + mockSendEmailInvitesToTeam.mockResolvedValue({ + members: [{email, error: undefined}], + error: undefined, + }); + + const result = await sendMembersInvites( + serverUrl, + teamId, + selectedIds, + false, + teamDisplayName, + formatMessage, + ); + + expect(result.error).toBe(false); + expect(result.sent).toHaveLength(2); + expect(result.notSent).toHaveLength(0); + }); + + it('should not invite guest users', async () => { + const userId = 'user-1'; + const user = {id: userId, roles: 'system_guest'} as UserProfile; + const selectedIds = {[userId]: user}; + + mockGetTeamMembersByIds.mockResolvedValue({ + members: [], + error: undefined, + }); + + const result = await sendMembersInvites( + serverUrl, + teamId, + selectedIds, + false, + teamDisplayName, + formatMessage, + ); + + expect(result.error).toBe(false); + expect(result.sent).toHaveLength(0); + expect(result.notSent).toHaveLength(1); + expect(result.notSent[0].userId).toBe(userId); + expect(result.notSent[0].reason).toBe('Contact your admin to make this guest a full member'); + expect(mockAddUsersToTeam).not.toHaveBeenCalled(); + }); + + it('should not invite users who are already team members', async () => { + const userId = 'user-1'; + const user = {id: userId, roles: 'system_user'} as UserProfile; + const selectedIds = {[userId]: user}; + + mockGetTeamMembersByIds.mockResolvedValue({ + members: [TestHelper.fakeTeamMember(userId, 'team-1')], + error: undefined, + }); + + const result = await sendMembersInvites( + serverUrl, + teamId, + selectedIds, + false, + teamDisplayName, + formatMessage, + ); + + expect(result.error).toBe(false); + expect(result.sent).toHaveLength(0); + expect(result.notSent).toHaveLength(1); + expect(result.notSent[0].userId).toBe(userId); + expect(mockAddUsersToTeam).not.toHaveBeenCalled(); + }); + + it('should handle errors from getTeamMembersByIds', async () => { + const userId = 'user-1'; + const user = {id: userId, roles: 'system_user'} as UserProfile; + const selectedIds = {[userId]: user}; + + mockGetTeamMembersByIds.mockResolvedValue({ + members: [], + error: {message: 'Database error'} as any, + }); + + const result = await sendMembersInvites( + serverUrl, + teamId, + selectedIds, + false, + teamDisplayName, + formatMessage, + ); + + expect(result.error).toBe(true); + expect(result.sent).toHaveLength(0); + expect(result.notSent).toHaveLength(0); + }); + + it('should handle errors from addUsersToTeam', async () => { + const userId = 'user-1'; + const user = {id: userId, roles: 'system_user'} as UserProfile; + const selectedIds = {[userId]: user}; + + mockGetTeamMembersByIds.mockResolvedValue({ + members: [], + error: undefined, + }); + + mockAddUsersToTeam.mockResolvedValue({ + members: [], + error: {message: 'Failed to add users'} as any, + }); + + const result = await sendMembersInvites( + serverUrl, + teamId, + selectedIds, + false, + teamDisplayName, + formatMessage, + ); + + expect(result.error).toBe(true); + expect(result.sent).toHaveLength(0); + expect(result.notSent).toHaveLength(0); + }); + + it('should handle individual user errors from addUsersToTeam', async () => { + const userId1 = 'user-1'; + const userId2 = 'user-2'; + const user1 = {id: userId1, roles: 'system_user'} as UserProfile; + const user2 = {id: userId2, roles: 'system_user'} as UserProfile; + const selectedIds = { + [userId1]: user1, + [userId2]: user2, + }; + + mockGetTeamMembersByIds.mockResolvedValue({ + members: [], + error: undefined, + }); + + mockAddUsersToTeam.mockResolvedValue({ + members: [ + {member: TestHelper.fakeTeamMember(userId1, 'team-1'), user_id: userId1, error: {message: 'User not found'} as any}, + {member: TestHelper.fakeTeamMember(userId2, 'team-1'), user_id: userId2, error: undefined}, + ], + error: undefined, + }); + + const result = await sendMembersInvites( + serverUrl, + teamId, + selectedIds, + false, + teamDisplayName, + formatMessage, + ); + + expect(result.error).toBe(false); + expect(result.sent).toHaveLength(1); + expect(result.notSent).toHaveLength(1); + expect(result.notSent[0].userId).toBe(userId1); + expect(result.notSent[0].reason).toBe('User not found'); + expect(result.sent[0].userId).toBe(userId2); + }); + + it('should handle errors from sendEmailInvitesToTeam', async () => { + const email = 'user@example.com'; + const selectedIds = {[email]: email}; + + mockSendEmailInvitesToTeam.mockResolvedValue({ + members: [], + error: {message: 'SMTP error'} as any, + }); + + const result = await sendMembersInvites( + serverUrl, + teamId, + selectedIds, + false, + teamDisplayName, + formatMessage, + ); + + expect(result.error).toBe(true); + expect(result.sent).toHaveLength(0); + expect(result.notSent).toHaveLength(0); + }); + + it('should handle individual email errors', async () => { + const email1 = 'user1@example.com'; + const email2 = 'user2@example.com'; + const selectedIds = { + [email1]: email1, + [email2]: email2, + }; + + mockSendEmailInvitesToTeam.mockResolvedValue({ + members: [ + {email: email1, error: {message: 'Invalid email'} as any}, + {email: email2, error: undefined}, + ], + error: undefined, + }); + + const result = await sendMembersInvites( + serverUrl, + teamId, + selectedIds, + false, + teamDisplayName, + formatMessage, + ); + + expect(result.error).toBe(false); + expect(result.sent).toHaveLength(1); + expect(result.notSent).toHaveLength(1); + expect(result.notSent[0].userId).toBe(email1); + expect(result.notSent[0].reason).toBe('Invalid email'); + expect(result.sent[0].userId).toBe(email2); + }); + + it('should show SMTP configuration error for admin when email fails with SEND_EMAIL_WITH_DEFAULTS_ERROR', async () => { + const email = 'user@example.com'; + const selectedIds = {[email]: email}; + + mockSendEmailInvitesToTeam.mockResolvedValue({ + members: [ + { + email, + error: { + message: 'SMTP error', + server_error_id: ServerErrors.SEND_EMAIL_WITH_DEFAULTS_ERROR, + } as any, + }, + ], + error: undefined, + }); + + const result = await sendMembersInvites( + serverUrl, + teamId, + selectedIds, + true, // isAdmin + teamDisplayName, + formatMessage, + ); + + expect(result.error).toBe(false); + expect(result.notSent).toHaveLength(1); + expect(result.notSent[0].reason).toContain('SMTP is not configured'); + expect(result.notSent[0].reason).toBe('SMTP is not configured in System Console'); + }); + + it('should show regular error message for non-admin when email fails with SEND_EMAIL_WITH_DEFAULTS_ERROR', async () => { + const email = 'user@example.com'; + const selectedIds = {[email]: email}; + + mockSendEmailInvitesToTeam.mockResolvedValue({ + members: [ + { + email, + error: { + message: 'SMTP error', + server_error_id: ServerErrors.SEND_EMAIL_WITH_DEFAULTS_ERROR, + } as any, + }, + ], + error: undefined, + }); + + const result = await sendMembersInvites( + serverUrl, + teamId, + selectedIds, + false, // not admin + teamDisplayName, + formatMessage, + ); + + expect(result.error).toBe(false); + expect(result.notSent).toHaveLength(1); + expect(result.notSent[0].reason).toBe('SMTP error'); + }); + }); + + describe('sendGuestInvites', () => { + const options = { + inviteAsGuest: true, + includeCustomMessage: false, + customMessage: '', + selectedChannels: ['channel-1', 'channel-2'], + guestMagicLink: false, + }; + + it('should successfully invite guest users', async () => { + const userId = 'user-1'; + const user = {id: userId, roles: 'system_guest'} as UserProfile; + const selectedIds = {[userId]: user}; + + mockAddUsersToTeam.mockResolvedValue({ + members: [{member: TestHelper.fakeTeamMember(userId, 'team-1'), user_id: userId, error: undefined}], + error: undefined, + }); + + mockAddMembersToChannel.mockResolvedValue({ + channelMemberships: [TestHelper.fakeChannelMember({user_id: userId, channel_id: 'channel-1'})], + error: undefined, + }); + + const result = await sendGuestInvites( + serverUrl, + teamId, + selectedIds, + options, + formatMessage, + ); + + expect(result.sent).toHaveLength(1); + expect(result.notSent).toHaveLength(0); + expect(result.sent[0].userId).toBe(userId); + }); + + it('should successfully invite guests via email', async () => { + const email = 'guest@example.com'; + const selectedIds = {[email]: email}; + + mockSendGuestEmailInvitesToTeam.mockResolvedValue({ + members: [{email, error: undefined}], + error: undefined, + }); + + const result = await sendGuestInvites( + serverUrl, + teamId, + selectedIds, + options, + formatMessage, + ); + + expect(result.sent).toHaveLength(1); + expect(result.notSent).toHaveLength(0); + expect(result.sent[0].email).toBe(email); + }); + + it('should handle mixed users and emails for guest invites', async () => { + const userId = 'user-1'; + const email = 'guest@example.com'; + const user = {id: userId, roles: 'system_guest'} as UserProfile; + const selectedIds = { + [userId]: user, + [email]: email, + }; + + mockAddUsersToTeam.mockResolvedValue({ + members: [{member: TestHelper.fakeTeamMember(userId, 'team-1'), user_id: userId, error: undefined}], + error: undefined, + }); + + mockAddMembersToChannel.mockResolvedValue({ + channelMemberships: [TestHelper.fakeChannelMember({user_id: userId, channel_id: 'channel-1'})], + error: undefined, + }); + + mockSendGuestEmailInvitesToTeam.mockResolvedValue({ + members: [{email, error: undefined}], + error: undefined, + }); + + const result = await sendGuestInvites( + serverUrl, + teamId, + selectedIds, + options, + formatMessage, + ); + + expect(result.sent).toHaveLength(2); + expect(result.notSent).toHaveLength(0); + }); + + it('should not invite non-guest users as guests', async () => { + const userId = 'user-1'; + const user = TestHelper.fakeUser({id: userId, roles: 'system_user'}); + const selectedIds = {[userId]: user}; + + const result = await sendGuestInvites( + serverUrl, + teamId, + selectedIds, + options, + formatMessage, + ); + + expect(result.sent).toHaveLength(0); + expect(result.notSent).toHaveLength(1); + expect(result.notSent[0].userId).toBe(userId); + expect(result.notSent[0].reason).toBe('This person is already a member of the workspace. Invite them as a member instead of a guest.'); + }); + + it('should handle errors from addUsersToTeam for guest invites', async () => { + const userId = 'user-1'; + const user = TestHelper.fakeUser({id: userId, roles: 'system_guest'}); + const selectedIds = {[userId]: user}; + + mockAddUsersToTeam.mockResolvedValue({ + members: [], + error: {message: 'Failed to add user'} as any, + }); + + const result = await sendGuestInvites( + serverUrl, + teamId, + selectedIds, + options, + formatMessage, + ); + + expect(result.sent).toHaveLength(0); + expect(result.notSent).toHaveLength(1); + expect(result.notSent[0].userId).toBe(userId); + expect(result.notSent[0].reason).toBe('Failed to add user'); + }); + + it('should handle errors when adding guest to channels', async () => { + const userId = 'user-1'; + const user = {id: userId, roles: 'system_guest'} as UserProfile; + const selectedIds = {[userId]: user}; + + mockAddUsersToTeam.mockResolvedValue({ + members: [{member: TestHelper.fakeTeamMember(userId, 'team-1'), user_id: userId, error: undefined}], + error: undefined, + }); + + mockAddMembersToChannel.mockResolvedValue({ + channelMemberships: [], + error: undefined, + }); + + const result = await sendGuestInvites( + serverUrl, + teamId, + selectedIds, + options, + formatMessage, + ); + + expect(result.sent).toHaveLength(0); + expect(result.notSent).toHaveLength(1); + expect(result.notSent[0].userId).toBe(userId); + expect(result.notSent[0].reason).toBe('Unable to add user to 2 channels'); + }); + + it('should handle errors from addMembersToChannel', async () => { + const userId = 'user-1'; + const user = {id: userId, roles: 'system_guest'} as UserProfile; + const selectedIds = {[userId]: user}; + + mockAddUsersToTeam.mockResolvedValue({ + members: [{member: TestHelper.fakeTeamMember(userId, 'team-1'), user_id: userId, error: undefined}], + error: undefined, + }); + + mockAddMembersToChannel.mockResolvedValue({ + channelMemberships: [], + error: {message: 'Channel error'} as any, + }); + + const result = await sendGuestInvites( + serverUrl, + teamId, + selectedIds, + options, + formatMessage, + ); + + expect(result.sent).toHaveLength(0); + expect(result.notSent.length).toBe(1); + expect(result.notSent[0].userId).toBe(userId); + expect(result.notSent[0].reason).toBe('Unable to add user to 2 channels'); + }); + + it('should handle partial channel failures (some channels succeed, some fail)', async () => { + const userId = 'user-1'; + const user = {id: userId, roles: 'system_guest'} as UserProfile; + const selectedIds = {[userId]: user}; + + mockAddUsersToTeam.mockResolvedValue({ + members: [{member: TestHelper.fakeTeamMember(userId, 'team-1'), user_id: userId, error: undefined}], + error: undefined, + }); + + // First channel succeeds, second fails with empty memberships + mockAddMembersToChannel. + mockResolvedValueOnce({ + channelMemberships: [TestHelper.fakeChannelMember({user_id: userId, channel_id: 'channel-1'})], + error: undefined, + }). + mockResolvedValueOnce({ + channelMemberships: [], + error: undefined, + }); + + const result = await sendGuestInvites( + serverUrl, + teamId, + selectedIds, + options, + formatMessage, + ); + + expect(result.sent).toHaveLength(0); + expect(result.notSent).toHaveLength(1); + expect(result.notSent[0].userId).toBe(userId); + expect(result.notSent[0].reason).toBe('Unable to add user to 1 channel'); + }); + + it('should successfully add guest to multiple channels', async () => { + const userId = 'user-1'; + const user = {id: userId, roles: 'system_guest'} as UserProfile; + const selectedIds = {[userId]: user}; + + mockAddUsersToTeam.mockResolvedValue({ + members: [{member: TestHelper.fakeTeamMember(userId, 'team-1'), user_id: userId, error: undefined}], + error: undefined, + }); + + mockAddMembersToChannel. + mockResolvedValueOnce({ + channelMemberships: [TestHelper.fakeChannelMember({user_id: userId, channel_id: 'channel-1'})], + error: undefined, + }). + mockResolvedValueOnce({ + channelMemberships: [TestHelper.fakeChannelMember({user_id: userId, channel_id: 'channel-2'})], + error: undefined, + }); + + const result = await sendGuestInvites( + serverUrl, + teamId, + selectedIds, + options, + formatMessage, + ); + + expect(result.sent).toHaveLength(1); + expect(result.notSent).toHaveLength(0); + expect(result.sent[0].userId).toBe(userId); + expect(result.sent[0].reason).toBe('This guest has been added to the team and 2 channels.'); + }); + + it('should handle errors from sendGuestEmailInvitesToTeam', async () => { + const email = 'guest@example.com'; + const selectedIds = {[email]: email}; + + mockSendGuestEmailInvitesToTeam.mockResolvedValue({ + members: [], + error: {message: 'Email error'} as any, + }); + + const result = await sendGuestInvites( + serverUrl, + teamId, + selectedIds, + options, + formatMessage, + ); + + expect(result.sent).toHaveLength(0); + expect(result.notSent).toHaveLength(1); + expect(result.notSent[0].userId).toBe(email); + expect(result.notSent[0].reason).toBe('Email error'); + }); + + it('should handle individual email errors from sendGuestEmailInvitesToTeam', async () => { + const email1 = 'guest1@example.com'; + const email2 = 'guest2@example.com'; + const selectedIds = { + [email1]: email1, + [email2]: email2, + }; + + mockSendGuestEmailInvitesToTeam.mockResolvedValue({ + members: [ + {email: email1, error: {message: 'Invalid email'} as any}, + {email: email2, error: undefined}, + ], + error: undefined, + }); + + const result = await sendGuestInvites( + serverUrl, + teamId, + selectedIds, + options, + formatMessage, + ); + + expect(result.sent).toHaveLength(1); + expect(result.notSent).toHaveLength(1); + expect(result.notSent[0].email).toBe(email1); + expect(result.notSent[0].reason).toBe('Invalid email'); + expect(result.sent[0].email).toBe(email2); + }); + + it('should include custom message when sending guest email invites', async () => { + const email = 'guest@example.com'; + const selectedIds = {[email]: email}; + const customOptions = { + ...options, + includeCustomMessage: true, + customMessage: 'Welcome to the team!', + }; + + mockSendGuestEmailInvitesToTeam.mockResolvedValue({ + members: [{email, error: undefined}], + error: undefined, + }); + + await sendGuestInvites( + serverUrl, + teamId, + selectedIds, + customOptions, + formatMessage, + ); + + expect(mockSendGuestEmailInvitesToTeam).toHaveBeenCalledWith( + serverUrl, + teamId, + [email], + options.selectedChannels, + 'Welcome to the team!', + false, + ); + }); + + it('should not include custom message when includeCustomMessage is false', async () => { + const email = 'guest@example.com'; + const selectedIds = {[email]: email}; + + const customOptions = { + ...options, + includeCustomMessage: false, + customMessage: 'Welcome to the team!', + }; + + mockSendGuestEmailInvitesToTeam.mockResolvedValue({ + members: [{email, error: undefined}], + error: undefined, + }); + + await sendGuestInvites( + serverUrl, + teamId, + selectedIds, + customOptions, + formatMessage, + ); + + expect(mockSendGuestEmailInvitesToTeam).toHaveBeenCalledWith( + serverUrl, + teamId, + [email], + options.selectedChannels, + '', + false, + ); + }); + + it('should send guest email invites with guest magic link', async () => { + const email = 'guest@example.com'; + const selectedIds = {[email]: email}; + const customOptions = { + ...options, + guestMagicLink: true, + }; + + mockSendGuestEmailInvitesToTeam.mockResolvedValue({ + members: [{email, error: undefined}], + error: undefined, + }); + + await sendGuestInvites( + serverUrl, + teamId, + selectedIds, + customOptions, + formatMessage, + ); + + expect(mockSendGuestEmailInvitesToTeam).toHaveBeenCalledWith( + serverUrl, + teamId, + [email], + options.selectedChannels, + '', + true, + ); + }); + + it('should handle empty members response from sendGuestEmailInvitesToTeam', async () => { + const email = 'guest@example.com'; + const selectedIds = {[email]: email}; + + mockSendGuestEmailInvitesToTeam.mockResolvedValue({ + members: [], + error: undefined, + }); + + const result = await sendGuestInvites( + serverUrl, + teamId, + selectedIds, + options, + formatMessage, + ); + + expect(result.sent).toHaveLength(0); + expect(result.notSent).toHaveLength(1); + expect(result.notSent[0].email).toBe(email); + expect(result.notSent[0].reason).toBe('Failed to send email invitation'); + }); + + it('should handle user error from addUsersToTeam member response', async () => { + const userId = 'user-1'; + const user = TestHelper.fakeUser({id: userId, roles: 'system_guest'}); + const selectedIds = {[userId]: user}; + + mockAddUsersToTeam.mockResolvedValue({ + members: [{member: TestHelper.fakeTeamMember(userId, 'team-1'), user_id: userId, error: {message: 'User error'} as any}], + error: undefined, + }); + + const result = await sendGuestInvites( + serverUrl, + teamId, + selectedIds, + options, + formatMessage, + ); + + expect(result.sent).toHaveLength(0); + expect(result.notSent).toHaveLength(1); + expect(result.notSent[0].userId).toBe(userId); + expect(result.notSent[0].reason).toBe('User error'); + }); + + it('should handle empty members array from addUsersToTeam', async () => { + const userId = 'user-1'; + const user = {id: userId, roles: 'system_guest'} as UserProfile; + const selectedIds = {[userId]: user}; + + mockAddUsersToTeam.mockResolvedValue({ + members: [], + error: undefined, + }); + + const result = await sendGuestInvites( + serverUrl, + teamId, + selectedIds, + options, + formatMessage, + ); + + expect(result.sent).toHaveLength(0); + expect(result.notSent).toHaveLength(1); + expect(result.notSent[0].userId).toBe(userId); + expect(result.notSent[0].reason).toBe('Unknown error'); + }); + }); +}); + diff --git a/app/screens/invite/actions.ts b/app/screens/invite/actions.ts new file mode 100644 index 000000000..27b231dd8 --- /dev/null +++ b/app/screens/invite/actions.ts @@ -0,0 +1,213 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {addMembersToChannel} from '@actions/remote/channel'; +import {addUsersToTeam, getTeamMembersByIds, sendEmailInvitesToTeam, sendGuestEmailInvitesToTeam} from '@actions/remote/team'; +import {ServerErrors} from '@constants'; +import {getErrorMessage} from '@utils/errors'; +import {secureGetFromRecord} from '@utils/types'; +import {isGuest} from '@utils/user'; + +import type {InviteResult, Result, SearchResult, SendOptions} from './types'; +import type {IntlShape} from 'react-intl'; + +export async function sendMembersInvites(serverUrl: string, teamId: string, selectedIds: {[id: string]: SearchResult}, isAdmin: boolean, teamDisplayName: string, formatMessage: IntlShape['formatMessage']) { + const userIds = []; + const emails = []; + + const errorResult: Result & {error: boolean} = {sent: [], notSent: [], error: true}; + + for (const [id, item] of Object.entries(selectedIds)) { + if (typeof item === 'string') { + emails.push(item); + } else { + userIds.push(id); + } + } + + const currentMemberIds = new Set(); + + if (userIds.length) { + const {members: currentTeamMembers = [], error: getTeamMembersByIdsError} = await getTeamMembersByIds(serverUrl, teamId, userIds); + + if (getTeamMembersByIdsError) { + return errorResult; + } + + for (const {user_id: currentMemberId} of currentTeamMembers) { + currentMemberIds.add(currentMemberId); + } + } + + const sent: InviteResult[] = []; + const notSent: InviteResult[] = []; + const usersToAdd = []; + + for (const userId of userIds) { + if (isGuest((selectedIds[userId] as UserProfile).roles)) { + notSent.push({userId, reason: formatMessage({id: 'invite.members.user_is_guest', defaultMessage: 'Contact your admin to make this guest a full member'})}); + } else if (currentMemberIds.has(userId)) { + notSent.push({userId, reason: formatMessage({id: 'invite.members.already_member', defaultMessage: 'This person is already a team member'})}); + } else { + usersToAdd.push(userId); + } + } + + if (usersToAdd.length) { + const {members, error: addUsersToTeamError} = await addUsersToTeam(serverUrl, teamId, usersToAdd); + + if (addUsersToTeamError) { + return errorResult; + } + + if (members) { + const membersWithError: Record = {}; + for (const {user_id, error} of members) { + if (error) { + membersWithError[user_id] = error.message; + } + } + + for (const userId of usersToAdd) { + if (membersWithError[userId]) { + notSent.push({userId, reason: membersWithError[userId]}); + } else { + sent.push({userId, reason: formatMessage({id: 'invite.summary.member_invite', defaultMessage: 'Invited as a member of {teamDisplayName}'}, {teamDisplayName})}); + } + } + } + } + + if (emails.length) { + const {members, error: sendEmailInvitesToTeamError} = await sendEmailInvitesToTeam(serverUrl, teamId, emails); + + if (sendEmailInvitesToTeamError) { + return errorResult; + } + + if (members) { + const membersWithError: Record = {}; + for (const {email, error} of members) { + if (error) { + membersWithError[email] = isAdmin && error.server_error_id === ServerErrors.SEND_EMAIL_WITH_DEFAULTS_ERROR ? ( + formatMessage({id: 'invite.summary.smtp_failure', defaultMessage: 'SMTP is not configured in System Console'}) + ) : ( + error.message + ); + } + } + + for (const email of emails) { + const error = secureGetFromRecord(membersWithError, email); + if (error) { + notSent.push({userId: email, reason: error}); + } else { + sent.push({userId: email, reason: formatMessage({id: 'invite.summary.email_invite', defaultMessage: 'An invitation email has been sent'})}); + } + } + } + } + + return {sent, notSent, error: false}; +} + +export async function sendGuestInvites(serverUrl: string, teamId: string, selectedIds: {[id: string]: SearchResult}, options: SendOptions, formatMessage: IntlShape['formatMessage']) { + const userIds = []; + const emails = []; + + for (const [id, item] of Object.entries(selectedIds)) { + if (typeof item === 'string') { + emails.push(item); + } else { + userIds.push(id); + } + } + + const sent: InviteResult[] = []; + const notSent: InviteResult[] = []; + + if (userIds.length) { + const {sent: sentForUsers, notSent: notSentForUsers} = await sendGuestInviteForUsers(serverUrl, teamId, selectedIds, userIds, options, formatMessage); + sent.push(...sentForUsers); + notSent.push(...notSentForUsers); + } + + if (emails.length) { + const {sent: sentForMails, notSent: notSentForMails} = await sendGuestInviteForMails(serverUrl, teamId, emails, options, formatMessage); + sent.push(...sentForMails); + notSent.push(...notSentForMails); + } + + return {sent, notSent}; +} + +async function sendGuestInviteForUsers(serverUrl: string, teamId: string, selectedIds: {[id: string]: SearchResult}, userIds: string[], options: SendOptions, formatMessage: IntlShape['formatMessage']) { + const sent: InviteResult[] = []; + const notSent: InviteResult[] = []; + + for await (const userId of userIds) { + const user = selectedIds[userId] as UserProfile; + if (!isGuest(user.roles)) { + notSent.push({userId, reason: formatMessage({id: 'invite.members.user_is_not_guest', defaultMessage: 'This person is already a member of the workspace. Invite them as a member instead of a guest.'})}); + continue; + } + + const {members, error} = await addUsersToTeam(serverUrl, teamId, [userId]); + if (error) { + notSent.push({userId, reason: getErrorMessage(error)}); + continue; + } + if (!members || members.length === 0 || members[0].error) { + notSent.push({userId, reason: getErrorMessage(members?.[0]?.error)}); + continue; + } + + let channelsAdded = 0; + let channelsFailed = 0; + for await (const channel of options.selectedChannels) { + const {channelMemberships, error: addMembersToChannelError} = await addMembersToChannel(serverUrl, channel, [userId]); + if (addMembersToChannelError) { + channelsFailed++; + continue; + } + if (!channelMemberships || channelMemberships.length === 0) { + channelsFailed++; + continue; + } + channelsAdded++; + } + + if (channelsFailed > 0) { + notSent.push({userId, reason: formatMessage({id: 'invite.summary.guest_invite_failed', defaultMessage: 'Unable to add user to {channelsFailed, plural, one {# channel} other {# channels}}'}, {channelsFailed})}); + continue; + } + + sent.push({userId, reason: formatMessage({id: 'invite.summary.guest_invite', defaultMessage: 'This guest has been added to the team and {count, plural, one {# channel} other {# channels}}.'}, {count: channelsAdded})}); + } + return {sent, notSent}; +} + +async function sendGuestInviteForMails(serverUrl: string, teamId: string, emails: string[], options: SendOptions, formatMessage: IntlShape['formatMessage']) { + const sent: InviteResult[] = []; + const notSent: InviteResult[] = []; + const message = options.includeCustomMessage ? options.customMessage : ''; + const response = await sendGuestEmailInvitesToTeam(serverUrl, teamId, emails, options.selectedChannels, message, options.guestMagicLink); + if (response.error) { + notSent.push(...emails.map((email) => ({userId: email, reason: getErrorMessage(response.error)}))); + return {sent, notSent}; + } + if (!response.members || response.members.length === 0) { + notSent.push(...emails.map((email) => ({email, reason: formatMessage({id: 'invite.summary.email_invite_failed', defaultMessage: 'Failed to send email invitation'})}))); + return {sent, notSent}; + } + + for (const member of response.members) { + if (member.error) { + notSent.push({email: member.email, reason: getErrorMessage(member.error)}); + continue; + } + sent.push({email: member.email, reason: formatMessage({id: 'invite.summary.email_invite', defaultMessage: 'An invitation email has been sent'})}); + } + + return {sent, notSent}; +} diff --git a/app/screens/invite/index.test.tsx b/app/screens/invite/index.test.tsx new file mode 100644 index 000000000..7c0a36440 --- /dev/null +++ b/app/screens/invite/index.test.tsx @@ -0,0 +1,283 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {type ComponentProps} from 'react'; + +import {General, Permissions, Preferences} from '@constants'; +import {SYSTEM_IDENTIFIERS} from '@constants/database'; +import DatabaseManager from '@database/manager'; +import {act, renderWithEverything, waitFor} from '@test/intl-test-helper'; +import TestHelper from '@test/test_helper'; + +import Invite from './invite'; + +import InviteContainer from './'; + +import type ServerDataOperator from '@database/operator/server_data_operator'; +import type {Database} from '@nozbe/watermelondb'; + +jest.mock('./invite'); +jest.mocked(Invite).mockImplementation( + (props) => React.createElement('Invite', {testID: 'invite-component', ...props}), +); + +const serverUrl = 'server-url'; + +describe('InviteContainer', () => { + let database: Database; + let operator: ServerDataOperator; + + beforeEach(async () => { + await DatabaseManager.init([serverUrl]); + const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + database = serverDatabaseAndOperator.database; + operator = serverDatabaseAndOperator.operator; + }); + + afterEach(() => { + DatabaseManager.destroyServerDatabase(serverUrl); + }); + + function getBaseProps(): ComponentProps { + return { + componentId: 'Invite' as any, + }; + } + + it('should render correctly with no data', () => { + const props = getBaseProps(); + const {getByTestId} = renderWithEverything(, {database}); + + const invite = getByTestId('invite-component'); + expect(invite).toBeTruthy(); + expect(invite.props.teamId).toBeUndefined(); + expect(invite.props.teamDisplayName).toBeUndefined(); + expect(invite.props.teammateNameDisplay).toBe(General.TEAMMATE_NAME_DISPLAY.SHOW_USERNAME); + expect(invite.props.isAdmin).toBe(false); + expect(invite.props.emailInvitationsEnabled).toBe(false); + expect(invite.props.canInviteGuests).toBe(false); + expect(invite.props.allowGuestMagicLink).toBe(false); + }); + + it('should render correctly with team data', async () => { + const team = TestHelper.fakeTeam({ + id: 'team-1', + display_name: 'Test Team', + invite_id: 'invite-id-1', + last_team_icon_update: 1234567890, + }); + + await operator.handleTeam({ + teams: [team], + prepareRecordsOnly: false, + }); + + await operator.handleSystem({ + systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: 'team-1'}], + prepareRecordsOnly: false, + }); + + const props = getBaseProps(); + const {getByTestId} = renderWithEverything(, {database}); + + const invite = getByTestId('invite-component'); + expect(invite.props.teamId).toBe('team-1'); + expect(invite.props.teamDisplayName).toBe('Test Team'); + expect(invite.props.teamInviteId).toBe('invite-id-1'); + expect(invite.props.teamLastIconUpdate).toBe(1234567890); + }); + + it('should render correctly with system config', async () => { + await operator.handleConfigs({ + configs: [ + { + id: 'EnableEmailInvitations', + value: 'true', + }, + ], + prepareRecordsOnly: false, + configsToDelete: [], + }); + + const props = getBaseProps(); + const {getByTestId} = renderWithEverything(, {database}); + + const invite = getByTestId('invite-component'); + expect(invite.props.emailInvitationsEnabled).toBe(true); + expect(invite.props.allowGuestMagicLink).toBe(false); + + await act(async () => { + await operator.handleConfigs({ + configs: [ + { + id: 'EnableGuestMagicLink', + value: 'true', + }, + ], + prepareRecordsOnly: false, + configsToDelete: [], + }); + }); + + await waitFor(() => { + expect(invite.props.emailInvitationsEnabled).toBe(true); + expect(invite.props.allowGuestMagicLink).toBe(true); + }); + }); + + it('should render correctly with user preferences', async () => { + await operator.handlePreferences({ + preferences: [{ + category: Preferences.CATEGORIES.DISPLAY_SETTINGS, + name: Preferences.NAME_NAME_FORMAT, + value: General.TEAMMATE_NAME_DISPLAY.SHOW_FULLNAME, + user_id: 'user-id', + }], + prepareRecordsOnly: false, + }); + + const props = getBaseProps(); + const {getByTestId} = renderWithEverything(, {database}); + + const invite = getByTestId('invite-component'); + expect(invite.props.teammateNameDisplay).toBe(General.TEAMMATE_NAME_DISPLAY.SHOW_FULLNAME); + }); + + it('should render correctly with admin user', async () => { + const user = TestHelper.fakeUser({ + id: 'user-1', + roles: 'system_admin', + }); + + await operator.handleUsers({ + users: [user], + prepareRecordsOnly: false, + }); + + await operator.handleSystem({ + systems: [{ + id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, + value: 'user-1', + }], + prepareRecordsOnly: false, + }); + + const props = getBaseProps(); + const {getByTestId} = renderWithEverything(, {database}); + + const invite = getByTestId('invite-component'); + expect(invite.props.isAdmin).toBe(true); + + user.roles = ''; + user.update_at = Date.now(); + await act(async () => { + await operator.handleUsers({ + users: [user], + prepareRecordsOnly: false, + }); + }); + + await waitFor(() => { + expect(invite.props.isAdmin).toBe(false); + }); + }); + + it('should calculate canInviteGuests correctly', async () => { + const team = TestHelper.fakeTeam({ + id: 'team-1', + group_constrained: false, + }); + + await operator.handleTeam({ + teams: [team], + prepareRecordsOnly: false, + }); + + await operator.handleMyTeam({ + myTeams: [{id: 'team-1', roles: 'team_admin'}], + prepareRecordsOnly: false, + }); + + await operator.handleRole({ + roles: [{id: 'team_admin', name: 'team_admin', permissions: [Permissions.INVITE_GUEST]}], + prepareRecordsOnly: false, + }); + + await operator.handleConfigs({ + configs: [{id: 'EnableGuestAccounts', value: 'true'}], + configsToDelete: [], + prepareRecordsOnly: false, + }); + + await operator.handleSystem({ + systems: [ + {id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: 'user-1'}, + {id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: 'team-1'}, + ], + prepareRecordsOnly: false, + }); + + const user = TestHelper.fakeUser({ + id: 'user-1', + }); + + await operator.handleUsers({ + users: [user], + prepareRecordsOnly: false, + }); + + const props = getBaseProps(); + const {getByTestId} = renderWithEverything(, {database}); + + const invite = getByTestId('invite-component'); + expect(invite.props.canInviteGuests).toBe(true); + + await act(async () => { + team.group_constrained = true; + team.update_at = Date.now(); + await operator.handleTeam({ + teams: [team], + prepareRecordsOnly: false, + }); + }); + + await waitFor(() => { + expect(invite.props.canInviteGuests).toBe(false); + }); + + await act(async () => { + team.group_constrained = false; + team.update_at = Date.now(); + await operator.handleTeam({ + teams: [team], + prepareRecordsOnly: false, + }); + + await operator.handleMyTeam({ + myTeams: [{id: 'team-1', roles: ''}], + prepareRecordsOnly: false, + }); + }); + + await waitFor(() => { + expect(invite.props.canInviteGuests).toBe(false); + }); + + await act(async () => { + await operator.handleMyTeam({ + myTeams: [{id: 'team-1', roles: 'team_admin'}], + prepareRecordsOnly: false, + }); + await operator.handleConfigs({ + configs: [{id: 'EnableGuestAccounts', value: 'false'}], + configsToDelete: [], + prepareRecordsOnly: false, + }); + }); + + await waitFor(() => { + expect(invite.props.canInviteGuests).toBe(false); + }); + }); +}); + diff --git a/app/screens/invite/index.ts b/app/screens/invite/index.ts index dbf39ceb9..0c9527b01 100644 --- a/app/screens/invite/index.ts +++ b/app/screens/invite/index.ts @@ -2,10 +2,12 @@ // See LICENSE.txt for license information. import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; -import {of as of$} from 'rxjs'; +import {combineLatest, of as of$} from 'rxjs'; import {switchMap, distinctUntilChanged, map} from 'rxjs/operators'; -import {observeConfigValue} from '@queries/servers/system'; +import {Permissions} from '@constants'; +import {observePermissionForTeam} from '@queries/servers/role'; +import {observeConfigBooleanValue} from '@queries/servers/system'; import {observeCurrentTeam} from '@queries/servers/team'; import {observeTeammateNameDisplay, observeCurrentUser} from '@queries/servers/user'; import {isSystemAdmin} from '@utils/user'; @@ -16,6 +18,19 @@ import type {WithDatabaseArgs} from '@typings/database/database'; const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { const team = observeCurrentTeam(database); + const currentUser = observeCurrentUser(database); + + const guestAccountsEnabled = observeConfigBooleanValue(database, 'EnableGuestAccounts'); + const emailInvitationsEnabled = observeConfigBooleanValue(database, 'EnableEmailInvitations'); + const isGroupConstrained = team.pipe( + switchMap((t) => of$(Boolean(t?.isGroupConstrained))), + ); + const hasPermissionToInviteGuests = combineLatest([team, currentUser]).pipe( + switchMap(([t, u]) => observePermissionForTeam(database, t, u, Permissions.INVITE_GUEST, false)), + ); + const canInviteGuests = combineLatest([isGroupConstrained, guestAccountsEnabled, hasPermissionToInviteGuests]).pipe( + switchMap(([group, enabled, permission]) => of$(!group && enabled && permission)), + ); return { teamId: team.pipe( @@ -35,7 +50,9 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { map((user) => isSystemAdmin(user?.roles || '')), distinctUntilChanged(), ), - allowPasswordlessInvites: observeConfigValue(database, 'EnableGuestMagicLink'), + emailInvitationsEnabled, + canInviteGuests, + allowGuestMagicLink: observeConfigBooleanValue(database, 'EnableGuestMagicLink'), }; }); diff --git a/app/screens/invite/invite.tsx b/app/screens/invite/invite.tsx index 0a92d87d6..ab612912b 100644 --- a/app/screens/invite/invite.tsx +++ b/app/screens/invite/invite.tsx @@ -6,13 +6,12 @@ import {type IntlShape, useIntl} from 'react-intl'; import {Keyboard, View, type LayoutChangeEvent} from 'react-native'; import {SafeAreaView} from 'react-native-safe-area-context'; -import {getTeamMembersByIds, addUsersToTeam, sendEmailInvitesToTeam} from '@actions/remote/team'; import {searchProfiles} from '@actions/remote/user'; import CompassIcon from '@components/compass_icon'; import Loading from '@components/loading'; -import {ServerErrors} from '@constants'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; +import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import {useKeyboardOverlap} from '@hooks/device'; import useNavButtonPressed from '@hooks/navigation_button_pressed'; import SecurityManager from '@managers/security_manager'; @@ -21,12 +20,12 @@ import {isEmail} from '@utils/helpers'; import {mergeNavigationOptions} from '@utils/navigation'; import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme'; import {secureGetFromRecord} from '@utils/types'; -import {isGuest} from '@utils/user'; +import {sendGuestInvites, sendMembersInvites} from './actions'; import Selection from './selection'; import Summary from './summary'; -import type UserModel from '@typings/database/models/servers/user'; +import type {EmailInvite, Result, SearchResult, SendOptions} from './types'; import type {AvailableScreens, NavButtons} from '@typings/screens/navigation'; import type {OptionsTopBarButton} from 'react-native-navigation'; @@ -70,20 +69,6 @@ const getStyleSheet = makeStyleSheetFromTheme(() => { }; }); -export type EmailInvite = string; - -export type SearchResult = UserProfile|UserModel|EmailInvite; - -export type InviteResult = { - userId: string; - reason: string; -}; - -export type Result = { - sent: InviteResult[]; - notSent: InviteResult[]; -} - enum Stage { SELECTION = 'selection', RESULT = 'result', @@ -98,6 +83,9 @@ type InviteProps = { teamInviteId: string; teammateNameDisplay: string; isAdmin: boolean; + emailInvitationsEnabled: boolean; + canInviteGuests: boolean; + allowGuestMagicLink: boolean; } export default function Invite({ @@ -108,9 +96,12 @@ export default function Invite({ teamInviteId, teammateNameDisplay, isAdmin, + emailInvitationsEnabled, + canInviteGuests, + allowGuestMagicLink, }: InviteProps) { const intl = useIntl(); - const {formatMessage, locale} = intl; + const {formatMessage} = intl; const theme = useTheme(); const styles = getStyleSheet(theme); const serverUrl = useServerUrl(); @@ -130,12 +121,29 @@ export default function Invite({ const [stage, setStage] = useState(Stage.SELECTION); const [sendError, setSendError] = useState(''); + const [sendOptions, setSendOptions] = useState({ + inviteAsGuest: false, + includeCustomMessage: false, + customMessage: '', + selectedChannels: [], + guestMagicLink: false, + }); + + const isResult = stage === Stage.RESULT; + const isSelecting = stage === Stage.SELECTION; + const selectedCount = Object.keys(selectedIds).length; + const hasSelection = selectedCount > 0; const onLayoutWrapper = useCallback((e: LayoutChangeEvent) => { setWrapperHeight(e.nativeEvent.layout.height); }, []); + const handleClearSearch = useCallback(() => { + setTerm(''); + setSearchResults([]); + }, []); + const searchUsers = useCallback(async (searchTerm: string) => { if (searchTerm === '') { handleClearSearch(); @@ -145,25 +153,19 @@ export default function Invite({ const {data} = await searchProfiles(serverUrl, searchTerm.toLowerCase(), {}); const results: SearchResult[] = data ?? []; - if (!results.length && isEmail(searchTerm.trim())) { + if (!results.length && isEmail(searchTerm.trim()) && emailInvitationsEnabled) { results.push(searchTerm.trim() as EmailInvite); } setSearchResults(results); - }, [serverUrl, teamId]); + }, [emailInvitationsEnabled, handleClearSearch, serverUrl]); - const handleReset = () => { - setStage(Stage.LOADING); + const handleReset = useCallback(() => { setSendError(''); setTerm(''); setSearchResults([]); setResult(DEFAULT_RESULT); setStage(Stage.SELECTION); - }; - - const handleClearSearch = useCallback(() => { - setTerm(''); - setSearchResults([]); }, []); const handleSearchChange = useCallback((text: string) => { @@ -194,128 +196,43 @@ export default function Invite({ handleClearSearch(); }, [selectedIds, handleClearSearch]); - const handleRetry = () => { + const handleSendError = useCallback(() => { + setSendError(formatMessage({id: 'invite.send_error', defaultMessage: 'Something went wrong while trying to send invitations. Please check your network connection and try again.'})); + setResult(DEFAULT_RESULT); + setStage(Stage.RESULT); + }, [formatMessage]); + + const handleSend = useCallback(async () => { + if (!hasSelection) { + return; + } + + setStage(Stage.LOADING); + + if (sendOptions.inviteAsGuest) { + const {sent, notSent} = await sendGuestInvites(serverUrl, teamId, selectedIds, sendOptions, formatMessage); + setResult({sent, notSent}); + setStage(Stage.RESULT); + return; + } + + const {sent, notSent, error} = await sendMembersInvites(serverUrl, teamId, selectedIds, isAdmin, teamDisplayName, formatMessage); + if (error) { + handleSendError(); + } else { + setResult({sent, notSent}); + setStage(Stage.RESULT); + } + }, [formatMessage, handleSendError, isAdmin, hasSelection, selectedIds, sendOptions, serverUrl, teamDisplayName, teamId]); + + const handleRetry = useCallback(() => { setSendError(''); setStage(Stage.LOADING); retryTimeoutId.current = setTimeout(() => { handleSend(); }, TIMEOUT_MILLISECONDS); - }; - - const handleSendError = () => { - setSendError(formatMessage({id: 'invite.send_error', defaultMessage: 'Something went wrong while trying to send invitations. Please check your network connection and try again.'})); - setResult(DEFAULT_RESULT); - setStage(Stage.RESULT); - }; - - const handleSend = async () => { - if (!selectedCount) { - return; - } - - setStage(Stage.LOADING); - - const userIds = []; - const emails = []; - - for (const [id, item] of Object.entries(selectedIds)) { - if (typeof item === 'string') { - emails.push(item); - } else { - userIds.push(id); - } - } - - const currentMemberIds = new Set(); - - if (userIds.length) { - const {members: currentTeamMembers = [], error: getTeamMembersByIdsError} = await getTeamMembersByIds(serverUrl, teamId, userIds); - - if (getTeamMembersByIdsError) { - handleSendError(); - return; - } - - for (const {user_id: currentMemberId} of currentTeamMembers) { - currentMemberIds.add(currentMemberId); - } - } - - const sent: InviteResult[] = []; - const notSent: InviteResult[] = []; - const usersToAdd = []; - - for (const userId of userIds) { - if (isGuest((selectedIds[userId] as UserProfile).roles)) { - notSent.push({userId, reason: formatMessage({id: 'invite.members.user_is_guest', defaultMessage: 'Contact your admin to make this guest a full member'})}); - } else if (currentMemberIds.has(userId)) { - notSent.push({userId, reason: formatMessage({id: 'invite.members.already_member', defaultMessage: 'This person is already a team member'})}); - } else { - usersToAdd.push(userId); - } - } - - if (usersToAdd.length) { - const {members, error: addUsersToTeamError} = await addUsersToTeam(serverUrl, teamId, usersToAdd); - - if (addUsersToTeamError) { - handleSendError(); - return; - } - - if (members) { - const membersWithError: Record = {}; - for (const {user_id, error} of members) { - if (error) { - membersWithError[user_id] = error.message; - } - } - - for (const userId of usersToAdd) { - if (membersWithError[userId]) { - notSent.push({userId, reason: membersWithError[userId]}); - } else { - sent.push({userId, reason: formatMessage({id: 'invite.summary.member_invite', defaultMessage: 'Invited as a member of {teamDisplayName}'}, {teamDisplayName})}); - } - } - } - } - - if (emails.length) { - const {members, error: sendEmailInvitesToTeamError} = await sendEmailInvitesToTeam(serverUrl, teamId, emails); - - if (sendEmailInvitesToTeamError) { - handleSendError(); - return; - } - - if (members) { - const membersWithError: Record = {}; - for (const {email, error} of members) { - if (error) { - membersWithError[email] = isAdmin && error.server_error_id === ServerErrors.SEND_EMAIL_WITH_DEFAULTS_ERROR ? ( - formatMessage({id: 'invite.summary.smtp_failure', defaultMessage: 'SMTP is not configured in System Console'}) - ) : ( - error.message - ); - } - } - - for (const email of emails) { - const error = secureGetFromRecord(membersWithError, email); - if (error) { - notSent.push({userId: email, reason: error}); - } else { - sent.push({userId: email, reason: formatMessage({id: 'invite.summary.email_invite', defaultMessage: 'An invitation email has been sent'})}); - } - } - } - } - - setResult({sent, notSent}); - setStage(Stage.RESULT); - }; + }, [handleSend]); useNavButtonPressed(CLOSE_BUTTON_ID, componentId, closeModal, [closeModal]); useNavButtonPressed(SEND_BUTTON_ID, componentId, handleSend, [handleSend]); @@ -323,18 +240,18 @@ export default function Invite({ useEffect(() => { const buttons: NavButtons = { leftButtons: [makeLeftButton(theme)], - rightButtons: stage === Stage.SELECTION ? [makeRightButton(theme, formatMessage, selectedCount > 0)] : [], + rightButtons: isSelecting ? [makeRightButton(theme, formatMessage, hasSelection)] : [], }; setButtons(componentId, buttons); - }, [theme, locale, componentId, selectedCount > 0, stage === Stage.SELECTION, sendError]); + }, [theme, componentId, hasSelection, isSelecting, formatMessage]); useEffect(() => { mergeNavigationOptions(componentId, { topBar: { title: { color: theme.sidebarHeaderTextColor, - text: stage === Stage.RESULT ? ( + text: isResult ? ( formatMessage({id: 'invite.title.summary', defaultMessage: 'Invite summary'}) ) : ( formatMessage({id: 'invite.title', defaultMessage: 'Invite'}) @@ -342,7 +259,7 @@ export default function Invite({ }, }, }); - }, [componentId, locale, theme, stage === Stage.RESULT]); + }, [componentId, formatMessage, isResult, theme]); useEffect(() => { return () => { @@ -364,6 +281,8 @@ export default function Invite({ setSelectedIds(newSelectedIds); }, [selectedIds]); + useAndroidHardwareBackHandler(componentId, closeModal); + const renderContent = () => { switch (stage) { case Stage.LOADING: @@ -406,6 +325,10 @@ export default function Invite({ onRemoveItem={handleRemoveItem} onClose={closeModal} testID='invite.screen.selection' + sendOptions={sendOptions} + onSendOptionsChange={setSendOptions} + canInviteGuests={canInviteGuests} + allowGuestMagicLink={allowGuestMagicLink} /> ); } diff --git a/app/screens/invite/selection.test.tsx b/app/screens/invite/selection.test.tsx new file mode 100644 index 000000000..94da1b1b1 --- /dev/null +++ b/app/screens/invite/selection.test.tsx @@ -0,0 +1,318 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {type ComponentProps} from 'react'; + +import SelectedChip from '@components/chips/selected_chip'; +import SelectedUserChip from '@components/chips/selected_user_chip'; +import FloatingTextInput from '@components/floating_input/floating_text_input_label'; +import OptionItem from '@components/option_item'; +import UserItem from '@components/user_item'; +import {Screens} from '@constants'; +import {goToScreen} from '@screens/navigation'; +import {fireEvent, renderWithIntl} from '@test/intl-test-helper'; +import TestHelper from '@test/test_helper'; + +import Selection from './selection'; +import SelectionSearchBar from './selection_search_bar'; +import SelectionTeamBar from './selection_team_bar'; +import TextItem from './text_item'; +import {TextItemType} from './types'; + +jest.mock('./selection_search_bar'); +jest.mocked(SelectionSearchBar).mockImplementation( + (props) => React.createElement('SelectionSearchBar', {testID: 'selection-search-bar', ...props}), +); + +jest.mock('./selection_team_bar'); +jest.mocked(SelectionTeamBar).mockImplementation( + (props) => React.createElement('SelectionTeamBar', {testID: 'selection-team-bar', ...props}), +); + +jest.mock('./text_item'); +jest.mocked(TextItem).mockImplementation( + (props) => React.createElement('TextItem', {...props}), +); + +jest.mock('@components/chips/selected_chip'); +jest.mocked(SelectedChip).mockImplementation( + (props) => React.createElement('SelectedChip', {testID: 'selected-chip', ...props}), +); + +jest.mock('@components/chips/selected_user_chip'); +jest.mocked(SelectedUserChip).mockImplementation( + (props) => React.createElement('SelectedUserChip', {testID: 'selected-user-chip', ...props}), +); + +jest.mock('@components/user_item'); +jest.mocked(UserItem).mockImplementation( + (props) => React.createElement('UserItem', {testID: 'user-item', ...props}), +); + +jest.mock('@components/option_item'); +jest.mocked(OptionItem).mockImplementation( + (props) => React.createElement('OptionItem', {testID: 'option-item', ...props}), +); + +jest.mock('@components/floating_input/floating_text_input_label', () => ({ + __esModule: true, + default: jest.fn(), +})); +jest.mocked(FloatingTextInput).mockImplementation( + (props) => React.createElement('FloatingTextInput', {testID: 'floating-text-input', ...props}), +); + +jest.mock('@screens/navigation', () => ({ + goToScreen: jest.fn(), +})); + +describe('Selection', () => { + const mockOnSearchChange = jest.fn(); + const mockOnSelectItem = jest.fn(); + const mockOnRemoveItem = jest.fn(); + const mockOnClose = jest.fn().mockResolvedValue(undefined); + const mockOnSendOptionsChange = jest.fn(); + + function getBaseProps(): ComponentProps { + return { + teamId: 'team-1', + teamDisplayName: 'Test Team', + teamLastIconUpdate: 1234567890, + teamInviteId: 'invite-id-1', + teammateNameDisplay: 'username', + serverUrl: 'https://test.server.com', + term: '', + searchResults: [], + selectedIds: {}, + keyboardOverlap: 0, + wrapperHeight: 800, + loading: false, + testID: 'invite.selection', + sendOptions: { + inviteAsGuest: false, + includeCustomMessage: false, + customMessage: '', + selectedChannels: [], + guestMagicLink: false, + }, + onSendOptionsChange: mockOnSendOptionsChange, + onSearchChange: mockOnSearchChange, + onSelectItem: mockOnSelectItem, + onRemoveItem: mockOnRemoveItem, + onClose: mockOnClose, + canInviteGuests: true, + allowGuestMagicLink: true, + }; + } + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly', () => { + const props = getBaseProps(); + const {getByTestId} = renderWithIntl(); + + expect(getByTestId('selection-search-bar')).toBeTruthy(); + expect(getByTestId('selection-team-bar')).toBeTruthy(); + }); + + it('renders selected items correctly', () => { + const props = getBaseProps(); + const user = TestHelper.fakeUser({id: 'user-1', username: 'user1'}); + props.selectedIds = { + 'user-1': user, + 'email-1': 'test@example.com', + }; + + const {getByTestId} = renderWithIntl(); + + expect(getByTestId('invite.selected_items')).toBeTruthy(); + const userChip = getByTestId('invite.selected_item'); + expect(userChip).toHaveProp('user', user); + expect(userChip).toHaveProp('teammateNameDisplay', 'username'); + userChip.props.onPress('user-1'); + expect(mockOnRemoveItem).toHaveBeenCalledTimes(1); + expect(mockOnRemoveItem).toHaveBeenCalledWith('user-1'); + const chip = getByTestId('invite.selected_item.test@example.com'); + expect(chip).toHaveProp('id', 'email-1'); + expect(chip).toHaveProp('text', 'test@example.com'); + chip.props.onRemove('email-1'); + expect(mockOnRemoveItem).toHaveBeenCalledTimes(2); + expect(mockOnRemoveItem).toHaveBeenCalledWith('email-1'); + }); + + it('does not render selected items when empty', () => { + const props = getBaseProps(); + const {queryByTestId} = renderWithIntl(); + + expect(queryByTestId('invite.selected_items')).toBeNull(); + }); + + it('renders search results correctly', () => { + const props = getBaseProps(); + const user = TestHelper.fakeUser({id: 'user-1', username: 'user1'}); + props.searchResults = [user, 'test@example.com']; + props.term = 'test'; + + const {getByTestId} = renderWithIntl(); + + expect(getByTestId('invite.search_list')).toBeVisible(); + const userItem = getByTestId('invite.search_list_user_item'); + expect(userItem).toHaveProp('user', user); + expect(userItem).toHaveProp('onUserPress', mockOnSelectItem); + userItem.props.onUserPress(user); + expect(mockOnSelectItem).toHaveBeenCalledTimes(1); + expect(mockOnSelectItem).toHaveBeenCalledWith(user); + const textItem = getByTestId('invite.search_list_text_item'); + expect(textItem).toHaveProp('text', 'test@example.com'); + expect(textItem).toHaveProp('type', TextItemType.SEARCH_INVITE); + fireEvent.press(textItem); + expect(mockOnSelectItem).toHaveBeenCalledTimes(2); + expect(mockOnSelectItem).toHaveBeenCalledWith('test@example.com'); + }); + + it('renders no results message when term exists and no results', () => { + const props = getBaseProps(); + props.term = 'nonexistent'; + props.searchResults = []; + props.loading = false; + + const {getByTestId} = renderWithIntl(); + + const textItem = getByTestId('invite.search_list_no_results'); + expect(textItem).toHaveProp('text', 'nonexistent'); + expect(textItem).toHaveProp('type', TextItemType.SEARCH_NO_RESULTS); + }); + + it('renders invite as guest option when canInviteGuests is true', () => { + const props = getBaseProps(); + const {getByTestId} = renderWithIntl(); + + const optionItem = getByTestId('invite.invite_as_guest'); + expect(optionItem).toHaveProp('label', 'Invite as guest'); + expect(optionItem).toHaveProp('type', 'toggle'); + expect(optionItem).toHaveProp('selected', false); + expect(optionItem).toHaveProp('action', expect.any(Function)); + expect(optionItem).toHaveProp('testID', 'invite.invite_as_guest'); + optionItem.props.action(); + expect(mockOnSendOptionsChange).toHaveBeenCalledTimes(1); + const setStateFunction = mockOnSendOptionsChange.mock.calls[0][0]; + const result = setStateFunction(props.sendOptions); + expect(result.inviteAsGuest).toBe(true); + }); + + it('does not render invite as guest option when canInviteGuests is false', () => { + const props = getBaseProps(); + props.canInviteGuests = false; + + const {queryByTestId} = renderWithIntl(); + + // The invite as guest option should not be rendered + const optionItem = queryByTestId('invite.invite_as_guest'); + expect(optionItem).toBeNull(); + }); + + it('renders custom message input when includeCustomMessage is true', () => { + const props = getBaseProps(); + props.sendOptions = { + inviteAsGuest: true, + includeCustomMessage: true, + customMessage: 'Test message', + selectedChannels: [], + guestMagicLink: false, + }; + + const {getByTestId} = renderWithIntl(); + + expect(getByTestId('invite.custom_message')).toBeTruthy(); + }); + + it('handles channel selection', () => { + const props = getBaseProps(); + props.sendOptions = { + inviteAsGuest: true, + includeCustomMessage: false, + customMessage: '', + selectedChannels: [], + guestMagicLink: false, + }; + + const {getByTestId} = renderWithIntl(); + + const channelOption = getByTestId('invite.selected_channels'); + channelOption.props.action(); + expect(goToScreen).toHaveBeenCalledWith( + Screens.INTEGRATION_SELECTOR, + expect.any(String), + expect.objectContaining({ + dataSource: 'channels', + isMultiselect: true, + }), + ); + }); + + it('renders guest magic link option when guestMagicLink is true', () => { + const props = getBaseProps(); + props.allowGuestMagicLink = true; + props.sendOptions = { + inviteAsGuest: true, + includeCustomMessage: false, + customMessage: '', + selectedChannels: [], + guestMagicLink: false, + }; + + const {getByTestId} = renderWithIntl(); + + const optionItem = getByTestId('invite.guest_magic_link'); + expect(optionItem).toHaveProp('label', 'Allow newly created guests to login without password'); + expect(optionItem).toHaveProp('type', 'toggle'); + expect(optionItem).toHaveProp('selected', false); + + optionItem.props.action(); + expect(mockOnSendOptionsChange).toHaveBeenCalledTimes(1); + const setStateFunction = mockOnSendOptionsChange.mock.calls[0][0]; + const result = setStateFunction(props.sendOptions); + expect(result.guestMagicLink).toBe(true); + }); + + it('does not render guest magic link option when allowGuestMagicLink is false', () => { + const props = getBaseProps(); + props.allowGuestMagicLink = false; + props.sendOptions = { + inviteAsGuest: true, + includeCustomMessage: false, + customMessage: '', + selectedChannels: [], + guestMagicLink: false, + }; + const {queryByTestId} = renderWithIntl(); + + const optionItem = queryByTestId('invite.guest_magic_link'); + expect(optionItem).toBeNull(); + }); + + it('passes correct props to SelectionSearchBar', () => { + const props = getBaseProps(); + props.term = 'test search'; + + renderWithIntl(); + + const searchBar = jest.mocked(SelectionSearchBar).mock.calls[0][0]; + expect(searchBar.term).toBe('test search'); + expect(searchBar.onSearchChange).toBe(mockOnSearchChange); + }); + + it('passes correct props to SelectionTeamBar', () => { + const props = getBaseProps(); + + renderWithIntl(); + + const teamBar = jest.mocked(SelectionTeamBar).mock.calls[0][0]; + expect(teamBar.teamId).toBe('team-1'); + expect(teamBar.teamDisplayName).toBe('Test Team'); + expect(teamBar.onClose).toBe(mockOnClose); + }); +}); + diff --git a/app/screens/invite/selection.tsx b/app/screens/invite/selection.tsx index 0c4b02a7f..7722b8db7 100644 --- a/app/screens/invite/selection.tsx +++ b/app/screens/invite/selection.tsx @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import React, {useCallback, useState, useMemo} from 'react'; +import {useIntl} from 'react-intl'; import { Keyboard, Platform, @@ -16,19 +17,22 @@ import Animated, {useAnimatedStyle, useDerivedValue} from 'react-native-reanimat import SelectedChip from '@components/chips/selected_chip'; import SelectedUserChip from '@components/chips/selected_user_chip'; +import FloatingTextInput from '@components/floating_input/floating_text_input_label'; +import OptionItem from '@components/option_item'; import TouchableWithFeedback from '@components/touchable_with_feedback'; import UserItem from '@components/user_item'; +import {Screens} from '@constants'; import {MAX_LIST_HEIGHT, MAX_LIST_TABLET_DIFF} from '@constants/autocomplete'; import {useTheme} from '@context/theme'; import {useAutocompleteDefaultAnimatedValues} from '@hooks/autocomplete'; import {useIsTablet} from '@hooks/device'; +import {goToScreen} from '@screens/navigation'; import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme'; import SelectionSearchBar from './selection_search_bar'; import SelectionTeamBar from './selection_team_bar'; -import TextItem, {TextItemType} from './text_item'; - -import type {SearchResult} from './invite'; +import TextItem from './text_item'; +import {TextItemType, type SearchResult, type SendOptions} from './types'; const AUTOCOMPLETE_ADJUST = 5; @@ -88,19 +92,28 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }, selectedItems: { display: 'flex', - flexGrowth: 1, }, selectedItemsContainer: { alignItems: 'flex-start', flexDirection: 'row', flexWrap: 'wrap', - marginHorizontal: 20, marginVertical: 16, gap: 8, }, + contentContainer: { + paddingHorizontal: 20, + }, + optionsContainer: { + marginTop: 16, + gap: 8, + }, }; }); +function extractChannelId(channelId: Channel) { + return channelId.id; +} + type SelectionProps = { teamId: string; teamDisplayName: string; @@ -115,10 +128,14 @@ type SelectionProps = { wrapperHeight: number; loading: boolean; testID: string; + sendOptions: SendOptions; + onSendOptionsChange: React.Dispatch>; onSearchChange: (text: string) => void; onSelectItem: (item: SearchResult) => void; onRemoveItem: (id: string) => void; onClose: () => Promise; + canInviteGuests: boolean; + allowGuestMagicLink: boolean; } export default function Selection({ @@ -139,15 +156,28 @@ export default function Selection({ onSelectItem, onRemoveItem, onClose, + sendOptions: { + inviteAsGuest, + includeCustomMessage, + customMessage, + selectedChannels, + guestMagicLink, + }, + onSendOptionsChange, + canInviteGuests, + allowGuestMagicLink, }: SelectionProps) { const theme = useTheme(); const styles = getStyleSheet(theme); const dimensions = useWindowDimensions(); const isTablet = useIsTablet(); + const intl = useIntl(); const [teamBarHeight, setTeamBarHeight] = useState(0); const [searchBarHeight, setSearchBarHeight] = useState(0); + const hasChannelsSelected = selectedChannels.length > 0; + const onLayoutSelectionTeamBar = useCallback((e: LayoutChangeEvent) => { setTeamBarHeight(e.nativeEvent.layout.height); }, []); @@ -156,9 +186,9 @@ export default function Selection({ setSearchBarHeight(e.nativeEvent.layout.height); }, []); - const handleOnRemoveItem = (id: string) => { + const handleOnRemoveItem = useCallback((id: string) => { onRemoveItem(id); - }; + }, [onRemoveItem]); const otherElementsSize = teamBarHeight + searchBarHeight; const workingSpace = wrapperHeight - keyboardOverlap; @@ -198,7 +228,7 @@ export default function Selection({ } return style; - }, [searchResults, styles, searchListContainerAnimatedStyle]); + }, [styles, searchListContainerAnimatedStyle]); const searchListFlatListStyle = useMemo(() => { const style = []; @@ -210,7 +240,7 @@ export default function Selection({ } return style; - }, [searchResults, styles, Boolean(term && !loading)]); + }, [loading, searchResults.length, styles, term]); const renderNoResults = useCallback(() => { if (!term || loading) { @@ -251,9 +281,56 @@ export default function Selection({ onUserPress={onSelectItem} /> ); - }, [searchResults, onSelectItem]); + }, [theme.buttonBg, onSelectItem]); - const renderSelectedItems = useMemo(() => { + const goToSelectorScreen = useCallback((() => { + const screen = Screens.INTEGRATION_SELECTOR; + const title = intl.formatMessage({id: 'invite.selected_channels', defaultMessage: 'Selected channels'}); + + const handleSelectChannels = (channels: Channel[]) => { + onSendOptionsChange((options) => ({ + ...options, + selectedChannels: channels.map(extractChannelId), + })); + }; + + goToScreen(screen, title, { + dataSource: 'channels', + handleSelect: handleSelectChannels, + selected: selectedChannels, + isMultiselect: true, + }); + }), [intl, selectedChannels, onSendOptionsChange]); + + const handleInviteAsGuestChange = useCallback(() => { + onSendOptionsChange((options) => ({ + ...options, + inviteAsGuest: !options.inviteAsGuest, + })); + }, [onSendOptionsChange]); + + const handleIncludeCustomMessageChange = useCallback(() => { + onSendOptionsChange((options) => ({ + ...options, + includeCustomMessage: !options.includeCustomMessage, + })); + }, [onSendOptionsChange]); + + const handleCustomMessageChange = useCallback((text: string) => { + onSendOptionsChange((options) => ({ + ...options, + customMessage: text, + })); + }, [onSendOptionsChange]); + + const handlePasswordlessInvitesChange = useCallback(() => { + onSendOptionsChange((options) => ({ + ...options, + guestMagicLink: !options.guestMagicLink, + })); + }, [onSendOptionsChange]); + + const renderSelectedItems = () => { const selectedItems = []; for (const id of Object.keys(selectedIds)) { @@ -279,7 +356,7 @@ export default function Selection({ } return selectedItems; - }, [selectedIds]); + }; return ( - - {Object.keys(selectedIds).length > 0 && ( - - {renderSelectedItems} - - )} + + + {Object.keys(selectedIds).length > 0 && ( + + {renderSelectedItems()} + + )} + + {canInviteGuests && ( + + )} + {inviteAsGuest && ( + <> + + + {includeCustomMessage && ( + + )} + {allowGuestMagicLink && ( + + )} + + )} + + { + const mockOnSearchChange = jest.fn(); + const mockOnLayoutContainer = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + function getBaseProps(): ComponentProps { + return { + term: '', + onSearchChange: mockOnSearchChange, + onLayoutContainer: mockOnLayoutContainer, + }; + } + + it('renders correctly', () => { + const props = getBaseProps(); + + const {getByTestId, getByText} = renderWithIntl( + , + ); + + expect(getByTestId('invite.search_bar')).toBeTruthy(); + expect(getByText('Send invitations to…')).toBeTruthy(); + expect(getByTestId('invite.search_bar_input')).toBeTruthy(); + }); + + it('calls onSearchChange when text changes', () => { + const props = getBaseProps(); + + const {getByTestId} = renderWithIntl( + , + ); + + const input = getByTestId('invite.search_bar_input'); + act(() => { + fireEvent.changeText(input, 'test'); + }); + + expect(mockOnSearchChange).toHaveBeenCalledWith('test'); + }); + + it('displays the term value', () => { + const props = getBaseProps(); + props.term = 'test@example.com'; + + const {getByTestId} = renderWithIntl( + , + ); + + const input = getByTestId('invite.search_bar_input'); + expect(input.props.value).toBe('test@example.com'); + }); +}); diff --git a/app/screens/invite/selection_search_bar.tsx b/app/screens/invite/selection_search_bar.tsx index 50e70f14f..2aaedc0e4 100644 --- a/app/screens/invite/selection_search_bar.tsx +++ b/app/screens/invite/selection_search_bar.tsx @@ -19,13 +19,11 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { display: 'flex', }, searchBarTitleText: { - marginHorizontal: 20, marginTop: SEARCH_BAR_TITLE_MARGIN_TOP, color: theme.centerChannelColor, ...typography('Heading', 700, 'SemiBold'), }, searchBar: { - marginHorizontal: 20, marginTop: SEARCH_BAR_MARGIN_TOP, }, searchInput: { @@ -65,17 +63,17 @@ export default function SelectionSearchBar({ onLayoutContainer(e); }, [onLayoutContainer]); - const onTextInputFocus = () => { + const onTextInputFocus = useCallback(() => { setIsFocused(true); - }; + }, []); - const onTextInputBlur = () => { + const onTextInputBlur = useCallback(() => { setIsFocused(false); - }; + }, []); - const handleSearchChange = (text: string) => { + const handleSearchChange = useCallback((text: string) => { onSearchChange(text); - }; + }, [onSearchChange]); const searchInputStyle = useMemo(() => { const style = []; @@ -90,7 +88,7 @@ export default function SelectionSearchBar({ } return style; - }, [isFocused, styles]); + }, [isFocused, styles.searchInput, theme.buttonBg]); return ( ({ + __esModule: true, + default: { + open: jest.fn(), + }, +})); + +jest.mock('@context/server', () => ({ + useServerDisplayName: jest.fn(), +})); + +jest.mock('@screens/navigation', () => ({ + dismissModal: jest.fn(), +})); + +jest.mock('@components/team_sidebar/team_list/team_item/team_icon', () => ({ + __esModule: true, + default: jest.fn(), +})); +jest.mocked(TeamIcon).mockImplementation( + (props) => React.createElement('TeamIcon', {testID: 'team-icon.mock', ...props}), +); + +describe('SelectionTeamBar', () => { + const mockOnLayoutContainer = jest.fn(); + const mockOnClose = jest.fn().mockResolvedValue(undefined); + const mockServerDisplayName = 'Test Server'; + + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(useServerDisplayName).mockReturnValue(mockServerDisplayName); + jest.mocked(Share.open).mockResolvedValue({success: true} as Awaited>); + }); + + function getBaseProps(): ComponentProps { + return { + teamId: 'team-1', + teamDisplayName: 'Test Team', + teamLastIconUpdate: 1234567890, + teamInviteId: 'invite-id-1', + serverUrl: 'https://test.server.com', + onLayoutContainer: mockOnLayoutContainer, + onClose: mockOnClose, + }; + } + + it('renders correctly', () => { + const props = getBaseProps(); + + const {getByText, getByTestId} = renderWithIntl( + , + ); + + expect(getByText('Test Team')).toBeTruthy(); + expect(getByText(mockServerDisplayName)).toBeTruthy(); + expect(getByText('Share link')).toBeTruthy(); + expect(getByTestId('invite.team_icon')).toBeTruthy(); + expect(getByTestId('invite.share_link.button')).toBeTruthy(); + }); + + it('handles share link press', async () => { + const props = getBaseProps(); + + const {getByTestId} = renderWithIntl( + , + ); + + const shareButton = getByTestId('invite.share_link.button'); + act(() => { + fireEvent.press(shareButton); + }); + + await waitFor(() => { + expect(mockOnClose).toHaveBeenCalled(); + expect(Share.open).toHaveBeenCalled(); + }); + }); +}); + diff --git a/app/screens/invite/summary.test.tsx b/app/screens/invite/summary.test.tsx new file mode 100644 index 000000000..f42ad2b48 --- /dev/null +++ b/app/screens/invite/summary.test.tsx @@ -0,0 +1,243 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {type ComponentProps} from 'react'; + +import AlertSvgComponent from '@components/illustrations/alert'; +import ErrorSvgComponent from '@components/illustrations/error'; +import SuccessSvgComponent from '@components/illustrations/success'; +import {act, fireEvent, renderWithIntl} from '@test/intl-test-helper'; +import TestHelper from '@test/test_helper'; + +import Summary from './summary'; +import SummaryReport from './summary_report'; + +import type {SearchResult} from './types'; + +jest.mock('./summary_report'); +jest.mocked(SummaryReport).mockImplementation( + (props) => React.createElement('SummaryReport', {...props}), +); + +jest.mock('@components/illustrations/success', () => ({ + __esModule: true, + default: jest.fn(), +})); +jest.mocked(SuccessSvgComponent).mockImplementation( + () => React.createElement('SuccessSvgComponent', {testID: 'success-svg'}), +); + +jest.mock('@components/illustrations/error', () => ({ + __esModule: true, + default: jest.fn(), +})); +jest.mocked(ErrorSvgComponent).mockImplementation( + () => React.createElement('ErrorSvgComponent', {testID: 'error-svg'}), +); + +jest.mock('@components/illustrations/alert', () => ({ + __esModule: true, + default: jest.fn(), +})); +jest.mocked(AlertSvgComponent).mockImplementation( + () => React.createElement('AlertSvgComponent', {testID: 'alert-svg'}), +); + +describe('Summary', () => { + const mockOnClose = jest.fn(); + const mockOnRetry = jest.fn(); + const mockOnBack = jest.fn(); + const mockSelectedIds: {[id: string]: SearchResult} = { + 'user-1': TestHelper.fakeUser({id: 'user-1', username: 'user1'}), + 'email-1': 'test@example.com', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + function getBaseProps(): ComponentProps { + return { + result: { + sent: [], + notSent: [], + }, + selectedIds: mockSelectedIds, + onClose: mockOnClose, + onRetry: mockOnRetry, + onBack: mockOnBack, + testID: 'invite.summary', + }; + } + + it('renders success state correctly', () => { + const result = { + sent: [ + {userId: 'user-1', reason: 'Invited as a member'}, + ], + notSent: [], + }; + + const props = getBaseProps(); + props.result = result; + + const {getByText, getByTestId} = renderWithIntl( + , + ); + + expect(getByText(/invitation has been sent/)).toBeTruthy(); + expect(getByText('Done')).toBeTruthy(); + expect(getByTestId('success-svg')).toBeTruthy(); + }); + + it('renders error state correctly', () => { + const result = { + sent: [], + notSent: [], + }; + const error = 'Something went wrong'; + + const props = getBaseProps(); + props.result = result; + props.error = error; + + const {getByText} = renderWithIntl( + , + ); + + expect(getByText(/could not be sent successfully/)).toBeTruthy(); + expect(getByText(error)).toBeTruthy(); + expect(getByText('Go back')).toBeTruthy(); + expect(getByText('Try again')).toBeTruthy(); + }); + + it('renders not sent state correctly', () => { + const result = { + sent: [], + notSent: [ + {userId: 'user-1', reason: 'Already a member'}, + ], + }; + + const props = getBaseProps(); + props.result = result; + + const {getByText, getByTestId} = renderWithIntl( + , + ); + + expect(getByText(/Invitation wasn’t sent/)).toBeTruthy(); + expect(getByTestId('error-svg')).toBeTruthy(); + }); + + it('renders partial success state correctly', () => { + const result = { + sent: [ + {userId: 'user-1', reason: 'Invited as a member'}, + ], + notSent: [ + {email: 'test@example.com', reason: 'Failed to send'}, + ], + }; + + const props = getBaseProps(); + props.result = result; + + const {getByText, getByTestId} = renderWithIntl( + , + ); + + expect(getByText(/An invitation was not sent/)).toBeTruthy(); + expect(getByTestId('alert-svg')).toBeTruthy(); + }); + + it('handles done button press', () => { + const result = { + sent: [ + {userId: 'user-1', reason: 'Invited as a member'}, + ], + notSent: [], + }; + + const props = getBaseProps(); + props.result = result; + + const {getByText} = renderWithIntl( + , + ); + + const doneButton = getByText('Done'); + act(() => { + fireEvent.press(doneButton); + }); + + expect(mockOnClose).toHaveBeenCalled(); + }); + + it('handles retry button press', () => { + const result = { + sent: [], + notSent: [], + }; + const error = 'Something went wrong'; + + const props = getBaseProps(); + props.result = result; + props.error = error; + + const {getByText} = renderWithIntl( + , + ); + + const retryButton = getByText('Try again'); + act(() => { + fireEvent.press(retryButton); + }); + + expect(mockOnRetry).toHaveBeenCalled(); + }); + + it('handles back button press', () => { + const result = { + sent: [], + notSent: [], + }; + const error = 'Something went wrong'; + + const props = getBaseProps(); + props.result = result; + props.error = error; + + const {getByText} = renderWithIntl( + , + ); + + const backButton = getByText('Go back'); + act(() => { + fireEvent.press(backButton); + }); + + expect(mockOnBack).toHaveBeenCalled(); + }); + + it('renders summary reports for sent and not sent', () => { + const result = { + sent: [ + {userId: 'user-1', reason: 'Invited as a member'}, + ], + notSent: [ + {email: 'test@example.com', reason: 'Failed to send'}, + ], + }; + + const props = getBaseProps(); + props.result = result; + + const {getAllByTestId} = renderWithIntl( + , + ); + + const reports = getAllByTestId('invite.summary_report'); + expect(reports).toHaveLength(2); + }); +}); diff --git a/app/screens/invite/summary.tsx b/app/screens/invite/summary.tsx index 68b3e0c07..f4df28b98 100644 --- a/app/screens/invite/summary.tsx +++ b/app/screens/invite/summary.tsx @@ -15,7 +15,7 @@ import {typography} from '@utils/typography'; import SummaryReport, {SummaryReportType} from './summary_report'; -import type {SearchResult, Result} from './invite'; +import type {SearchResult, Result} from './types'; const MAX_WIDTH_CONTENT = 480; diff --git a/app/screens/invite/summary_report.test.tsx b/app/screens/invite/summary_report.test.tsx new file mode 100644 index 000000000..09456c690 --- /dev/null +++ b/app/screens/invite/summary_report.test.tsx @@ -0,0 +1,111 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {type ComponentProps} from 'react'; + +import UserItem from '@components/user_item'; +import {renderWithIntl} from '@test/intl-test-helper'; +import TestHelper from '@test/test_helper'; + +import SummaryReport, {SummaryReportType} from './summary_report'; +import TextItem from './text_item'; + +import type {SearchResult} from './types'; + +jest.mock('./text_item'); +jest.mocked(TextItem).mockImplementation( + (props) => React.createElement('TextItem', {...props}), +); + +jest.mock('@components/user_item', () => ({ + __esModule: true, + default: jest.fn(), +})); +jest.mocked(UserItem).mockImplementation( + (props) => React.createElement('UserItem', {...props}), +); + +describe('SummaryReport', () => { + const mockSelectedIds: {[id: string]: SearchResult} = { + 'user-1': TestHelper.fakeUser({id: 'user-1', username: 'user1'}), + 'email-1': 'test@example.com', + }; + + function getBaseProps(): ComponentProps { + return { + type: SummaryReportType.SENT, + invites: [], + selectedIds: mockSelectedIds, + testID: 'invite.summary_report', + }; + } + + it('renders sent type correctly', () => { + const invites = [ + {userId: 'user-1', reason: 'Invited as a member'}, + ]; + + const props = getBaseProps(); + props.type = SummaryReportType.SENT; + props.invites = invites; + + const {getByText, getByTestId} = renderWithIntl( + , + ); + + expect(getByText(/1 successful invitation/)).toBeTruthy(); + expect(getByTestId('invite.summary_report.sent')).toBeTruthy(); + }); + + it('renders not sent type correctly', () => { + const invites = [ + {userId: 'user-1', reason: 'Already a member'}, + ]; + + const props = getBaseProps(); + props.type = SummaryReportType.NOT_SENT; + props.invites = invites; + + const {getByText, getByTestId} = renderWithIntl( + , + ); + + expect(getByText(/1 invitation not sent/)).toBeTruthy(); + expect(getByTestId('invite.summary_report.not_sent')).toBeTruthy(); + }); + + it('renders email invites correctly', () => { + const invites = [ + {email: 'test@example.com', reason: 'An invitation email has been sent'}, + ]; + + const props = getBaseProps(); + props.type = SummaryReportType.SENT; + props.invites = invites; + + const {getByText} = renderWithIntl( + , + ); + + expect(getByText('An invitation email has been sent')).toBeTruthy(); + }); + + it('renders multiple invites correctly', () => { + const invites = [ + {userId: 'user-1', reason: 'Invited as a member'}, + {email: 'test@example.com', reason: 'An invitation email has been sent'}, + ]; + + const props = getBaseProps(); + props.type = SummaryReportType.SENT; + props.invites = invites; + + const {getByText} = renderWithIntl( + , + ); + + expect(getByText('Invited as a member')).toBeTruthy(); + expect(getByText('An invitation email has been sent')).toBeTruthy(); + }); +}); + diff --git a/app/screens/invite/summary_report.tsx b/app/screens/invite/summary_report.tsx index 35ec7f41b..c771d88ed 100644 --- a/app/screens/invite/summary_report.tsx +++ b/app/screens/invite/summary_report.tsx @@ -12,9 +12,8 @@ import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme'; import {secureGetFromRecord} from '@utils/types'; import {typography} from '@utils/typography'; -import TextItem, {TextItemType} from './text_item'; - -import type {SearchResult, InviteResult} from './invite'; +import TextItem from './text_item'; +import {type SearchResult, type InviteResult, TextItemType} from './types'; const COLOR_SUCCESS = '#3db887'; const COLOR_ERROR = '#d24b4e'; @@ -29,12 +28,12 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { borderRadius: 4, marginBottom: 16, paddingVertical: 8, + paddingHorizontal: 20, }, title: { display: 'flex', flexDirection: 'row', alignItems: 'center', - paddingHorizontal: 20, paddingVertical: 12, }, titleText: { @@ -48,8 +47,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { paddingVertical: 12, }, reason: { - paddingLeft: 56, - paddingRight: 20, + paddingLeft: 36, ...typography('Body', 75, 'Regular'), color: changeOpacity(theme.centerChannelColor, 0.64), }, @@ -114,12 +112,13 @@ export default function SummaryReport({ {message} - {invites.map(({userId, reason}) => { - const item = secureGetFromRecord(selectedIds, userId); + {invites.map(({userId, email, reason}) => { + const key = userId ?? email ?? ''; + const item = secureGetFromRecord(selectedIds, key); return ( {typeof item === 'string' ? ( diff --git a/app/screens/invite/text_item.test.tsx b/app/screens/invite/text_item.test.tsx new file mode 100644 index 000000000..07ca45d93 --- /dev/null +++ b/app/screens/invite/text_item.test.tsx @@ -0,0 +1,55 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {renderWithIntl} from '@test/intl-test-helper'; + +import TextItem from './text_item'; +import {TextItemType} from './types'; + +jest.mock('@components/compass_icon', () => ({ + __esModule: true, + default: jest.fn(), +})); + +describe('TextItem', () => { + it('renders search invite type correctly', () => { + const {getByText} = renderWithIntl( + , + ); + + expect(getByText('invite')).toBeTruthy(); + expect(getByText('test@example.com')).toBeTruthy(); + }); + + it('renders search no results type correctly', () => { + const {getByText} = renderWithIntl( + , + ); + + expect(getByText('No one found matching')).toBeTruthy(); + expect(getByText('test@example.com')).toBeTruthy(); + }); + + it('renders summary type correctly', () => { + const {getByText} = renderWithIntl( + , + ); + + expect(getByText('test@example.com')).toBeTruthy(); + }); +}); + diff --git a/app/screens/invite/text_item.tsx b/app/screens/invite/text_item.tsx index 593d092f2..c7af23f45 100644 --- a/app/screens/invite/text_item.tsx +++ b/app/screens/invite/text_item.tsx @@ -10,6 +10,8 @@ import {useTheme} from '@context/theme'; import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme'; import {typography} from '@utils/typography'; +import {TextItemType} from './types'; + const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { return { item: { @@ -44,15 +46,12 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { itemIcon: { color: changeOpacity(theme.centerChannelColor, 0.56), }, + flex: { + flex: 1, + }, }; }); -export enum TextItemType { - SEARCH_INVITE = 'search_invite', - SEARCH_NO_RESULTS = 'search_no_results', - SUMMARY = 'summary', -} - type TextItemProps = { text?: string; type: TextItemType; @@ -73,7 +72,7 @@ export default function TextItem({ return ( {email && ( @@ -98,7 +97,7 @@ export default function TextItem({ )} diff --git a/app/screens/invite/types.ts b/app/screens/invite/types.ts new file mode 100644 index 000000000..b607688b7 --- /dev/null +++ b/app/screens/invite/types.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type UserModel from '@typings/database/models/servers/user'; + +export type SendOptions = { + inviteAsGuest: boolean; + includeCustomMessage: boolean; + customMessage: string; + selectedChannels: string[]; + guestMagicLink: boolean; +} + +export type EmailInvite = string; + +export type SearchResult = UserProfile|UserModel|EmailInvite; + +export type InviteResult = { + userId?: string; + email?: string; + reason: string; +}; + +export type Result = { + sent: InviteResult[]; + notSent: InviteResult[]; +} + +export enum TextItemType { + SEARCH_INVITE = 'search_invite', + SEARCH_NO_RESULTS = 'search_no_results', + SUMMARY = 'summary', +} diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 7750f4e71..fddb5290c 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -440,19 +440,31 @@ "intro.welcome.public": "Add some more team members to the channel or start a conversation below.", "invite_people_to_team.message": "Here’s a link to collaborate and communicate with us on Mattermost.", "invite_people_to_team.title": "Join the {team} team", + "invite.custom_message": "Enter a custom message", + "invite.guest_magic_link": "Allow newly created guests to login without password", + "invite.invite_as_guest": "Invite as guest", + "invite.invite_as_guest_description": "Guests are limited to selected channels", "invite.members.already_member": "This person is already a team member", "invite.members.user_is_guest": "Contact your admin to make this guest a full member", + "invite.members.user_is_not_guest": "This person is already a member of the workspace. Invite them as a member instead of a guest.", + "invite.no_channels_selected": "Required for guests", "invite.search.email_invite": "invite", "invite.search.no_results": "No one found matching", "invite.searchPlaceholder": "Type a name or email address…", + "invite.selected_channels": "Selected channels", + "invite.selected_channels_count": "{count} {count, plural, one{channel} other{channels}}", "invite.send_error": "Something went wrong while trying to send invitations. Please check your network connection and try again.", "invite.send_invite": "Send", "invite.sendInvitationsTo": "Send invitations to…", + "invite.set_custom_message": "Set a custom message", "invite.shareLink": "Share link", "invite.summary.back": "Go back", "invite.summary.done": "Done", "invite.summary.email_invite": "An invitation email has been sent", + "invite.summary.email_invite_failed": "Failed to send email invitation", "invite.summary.error": "{invitationsCount, plural, one {Invitation} other {Invitations}} could not be sent successfully", + "invite.summary.guest_invite": "This guest has been added to the team and {count, plural, one {# channel} other {# channels}}.", + "invite.summary.guest_invite_failed": "Unable to add user to {channelsFailed, plural, one {# channel} other {# channels}}", "invite.summary.member_invite": "Invited as a member of {teamDisplayName}", "invite.summary.not_sent": "{notSentCount, plural, one {Invitation wasn’t} other {Invitations weren’t}} sent", "invite.summary.report.notSent": "{count} {count, plural, one {invitation} other {invitations}} not sent", diff --git a/types/api/teams.d.ts b/types/api/teams.d.ts index 5fe283f0a..69f7111e9 100644 --- a/types/api/teams.d.ts +++ b/types/api/teams.d.ts @@ -16,12 +16,12 @@ type TeamMembership = { type TeamMemberWithError = { member: TeamMembership; user_id: string; - error: ApiError; + error?: ApiError; } type TeamInviteWithError = { email: string; - error: ApiError; + error?: ApiError; } type TeamType = 'O' | 'I';