Add guest invites (#9288)
* Add guest invites * Fix tests and address copilot feedback * Fix test * Add magic link changes
This commit is contained in:
parent
50372294c1
commit
d76791ebe6
25 changed files with 2802 additions and 215 deletions
|
|
@ -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<TeamInviteWithError[]>([{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};
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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});
|
||||
|
|
|
|||
|
|
@ -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<Team>;
|
||||
|
|
@ -24,6 +25,7 @@ export interface ClientTeamsMix {
|
|||
addToTeam: (teamId: string, userId: string) => Promise<TeamMembership>;
|
||||
addUsersToTeamGracefully: (teamId: string, userIds: string[]) => Promise<TeamMemberWithError[]>;
|
||||
sendEmailInvitesToTeamGracefully: (teamId: string, emails: string[]) => Promise<TeamInviteWithError[]>;
|
||||
sendGuestEmailInvitesToTeamGracefully: (teamId: string, emails: string[], channels: string[], message?: string, magicLink?: boolean) => Promise<TeamInviteWithError[]>;
|
||||
joinTeam: (inviteId: string) => Promise<TeamMembership>;
|
||||
removeFromTeam: (teamId: string, userId: string) => Promise<any>;
|
||||
getTeamStats: (teamId: string) => Promise<any>;
|
||||
|
|
@ -147,6 +149,19 @@ const ClientTeams = <TBase extends Constructor<ClientBase>>(superclass: TBase) =
|
|||
);
|
||||
};
|
||||
|
||||
sendGuestEmailInvitesToTeamGracefully = (teamId: string, emails: string[], channels: string[], message = '', guestMagicLink = false) => {
|
||||
const params: FirstArgument<typeof buildQueryString> = {
|
||||
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 = <TBase extends Constructor<ClientBase>>(superclass: TBase) =
|
|||
};
|
||||
|
||||
getTeamIconUrl = (teamId: string, lastTeamIconUpdate: number) => {
|
||||
const params: any = {};
|
||||
const params: FirstArgument<typeof buildQueryString> = {};
|
||||
if (lastTeamIconUpdate) {
|
||||
params._ = lastTeamIconUpdate;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
<Text
|
||||
style={[styles.info, !actionComponent && styles.iconContainer, destructive && {color: theme.dndIndicator}]}
|
||||
style={[styles.info, !actionComponent && styles.iconContainer, (destructive || isInfoDestructive) && {color: theme.dndIndicator}]}
|
||||
testID={`${testID}.info`}
|
||||
numberOfLines={1}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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(<OptionItem {...props}/>);
|
||||
|
||||
const info = getByText('Test info');
|
||||
expect(info).toHaveStyle({color: Preferences.THEMES.denim.dndIndicator});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
900
app/screens/invite/actions.test.ts
Normal file
900
app/screens/invite/actions.test.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
213
app/screens/invite/actions.ts
Normal file
213
app/screens/invite/actions.ts
Normal file
|
|
@ -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<string, string> = {};
|
||||
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<string, string> = {};
|
||||
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};
|
||||
}
|
||||
283
app/screens/invite/index.test.tsx
Normal file
283
app/screens/invite/index.test.tsx
Normal file
|
|
@ -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<typeof InviteContainer> {
|
||||
return {
|
||||
componentId: 'Invite' as any,
|
||||
};
|
||||
}
|
||||
|
||||
it('should render correctly with no data', () => {
|
||||
const props = getBaseProps();
|
||||
const {getByTestId} = renderWithEverything(<InviteContainer {...props}/>, {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(<InviteContainer {...props}/>, {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(<InviteContainer {...props}/>, {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(<InviteContainer {...props}/>, {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(<InviteContainer {...props}/>, {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(<InviteContainer {...props}/>, {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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -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'),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<SendOptions>({
|
||||
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<string, string> = {};
|
||||
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<string, string> = {};
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
318
app/screens/invite/selection.test.tsx
Normal file
318
app/screens/invite/selection.test.tsx
Normal file
|
|
@ -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<typeof Selection> {
|
||||
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(<Selection {...props}/>);
|
||||
|
||||
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(<Selection {...props}/>);
|
||||
|
||||
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(<Selection {...props}/>);
|
||||
|
||||
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(<Selection {...props}/>);
|
||||
|
||||
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(<Selection {...props}/>);
|
||||
|
||||
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(<Selection {...props}/>);
|
||||
|
||||
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(<Selection {...props}/>);
|
||||
|
||||
// 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(<Selection {...props}/>);
|
||||
|
||||
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(<Selection {...props}/>);
|
||||
|
||||
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(<Selection {...props}/>);
|
||||
|
||||
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(<Selection {...props}/>);
|
||||
|
||||
const optionItem = queryByTestId('invite.guest_magic_link');
|
||||
expect(optionItem).toBeNull();
|
||||
});
|
||||
|
||||
it('passes correct props to SelectionSearchBar', () => {
|
||||
const props = getBaseProps();
|
||||
props.term = 'test search';
|
||||
|
||||
renderWithIntl(<Selection {...props}/>);
|
||||
|
||||
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(<Selection {...props}/>);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -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<React.SetStateAction<SendOptions>>;
|
||||
onSearchChange: (text: string) => void;
|
||||
onSelectItem: (item: SearchResult) => void;
|
||||
onRemoveItem: (id: string) => void;
|
||||
onClose: () => Promise<void>;
|
||||
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 (
|
||||
<View
|
||||
|
|
@ -295,20 +372,73 @@ export default function Selection({
|
|||
onLayoutContainer={onLayoutSelectionTeamBar}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<SelectionSearchBar
|
||||
term={term}
|
||||
onSearchChange={onSearchChange}
|
||||
onLayoutContainer={onLayoutSearchBar}
|
||||
/>
|
||||
{Object.keys(selectedIds).length > 0 && (
|
||||
<ScrollView
|
||||
style={styles.selectedItems}
|
||||
contentContainerStyle={styles.selectedItemsContainer}
|
||||
testID='invite.selected_items'
|
||||
>
|
||||
{renderSelectedItems}
|
||||
</ScrollView>
|
||||
)}
|
||||
<View style={styles.contentContainer}>
|
||||
<SelectionSearchBar
|
||||
term={term}
|
||||
onSearchChange={onSearchChange}
|
||||
onLayoutContainer={onLayoutSearchBar}
|
||||
/>
|
||||
{Object.keys(selectedIds).length > 0 && (
|
||||
<ScrollView
|
||||
style={styles.selectedItems}
|
||||
contentContainerStyle={styles.selectedItemsContainer}
|
||||
testID='invite.selected_items'
|
||||
>
|
||||
{renderSelectedItems()}
|
||||
</ScrollView>
|
||||
)}
|
||||
<View style={styles.optionsContainer}>
|
||||
{canInviteGuests && (
|
||||
<OptionItem
|
||||
label={intl.formatMessage({id: 'invite.invite_as_guest', defaultMessage: 'Invite as guest'})}
|
||||
description={intl.formatMessage({id: 'invite.invite_as_guest_description', defaultMessage: 'Guests are limited to selected channels'})}
|
||||
type='toggle'
|
||||
selected={inviteAsGuest}
|
||||
action={handleInviteAsGuestChange}
|
||||
testID='invite.invite_as_guest'
|
||||
/>
|
||||
)}
|
||||
{inviteAsGuest && (
|
||||
<>
|
||||
<OptionItem
|
||||
label={intl.formatMessage({id: 'invite.selected_channels', defaultMessage: 'Selected channels'})}
|
||||
type='arrow'
|
||||
action={goToSelectorScreen}
|
||||
info={hasChannelsSelected ? intl.formatMessage({id: 'invite.selected_channels_count', defaultMessage: '{count} {count, plural, one{channel} other{channels}}'}, {count: selectedChannels.length}) : intl.formatMessage({id: 'invite.no_channels_selected', defaultMessage: 'Required for guests'})}
|
||||
isInfoDestructive={!hasChannelsSelected}
|
||||
testID='invite.selected_channels'
|
||||
icon={'globe'}
|
||||
/>
|
||||
<OptionItem
|
||||
label={intl.formatMessage({id: 'invite.set_custom_message', defaultMessage: 'Set a custom message'})}
|
||||
type='toggle'
|
||||
selected={includeCustomMessage}
|
||||
action={handleIncludeCustomMessageChange}
|
||||
testID='invite.include_custom_message'
|
||||
/>
|
||||
{includeCustomMessage && (
|
||||
<FloatingTextInput
|
||||
label={intl.formatMessage({id: 'invite.custom_message', defaultMessage: 'Enter a custom message'})}
|
||||
value={customMessage}
|
||||
onChangeText={handleCustomMessageChange}
|
||||
testID='invite.custom_message'
|
||||
theme={theme}
|
||||
multiline={true}
|
||||
/>
|
||||
)}
|
||||
{allowGuestMagicLink && (
|
||||
<OptionItem
|
||||
label={intl.formatMessage({id: 'invite.guest_magic_link', defaultMessage: 'Allow newly created guests to login without password'})}
|
||||
type='toggle'
|
||||
selected={guestMagicLink}
|
||||
action={handlePasswordlessInvitesChange}
|
||||
testID='invite.guest_magic_link'
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<Animated.View style={searchListContainerStyle}>
|
||||
<FlatList
|
||||
data={searchResults}
|
||||
|
|
|
|||
64
app/screens/invite/selection_search_bar.test.tsx
Normal file
64
app/screens/invite/selection_search_bar.test.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {type ComponentProps} from 'react';
|
||||
|
||||
import {act, fireEvent, renderWithIntl} from '@test/intl-test-helper';
|
||||
|
||||
import SelectionSearchBar from './selection_search_bar';
|
||||
|
||||
describe('SelectionSearchBar', () => {
|
||||
const mockOnSearchChange = jest.fn();
|
||||
const mockOnLayoutContainer = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
function getBaseProps(): ComponentProps<typeof SelectionSearchBar> {
|
||||
return {
|
||||
term: '',
|
||||
onSearchChange: mockOnSearchChange,
|
||||
onLayoutContainer: mockOnLayoutContainer,
|
||||
};
|
||||
}
|
||||
|
||||
it('renders correctly', () => {
|
||||
const props = getBaseProps();
|
||||
|
||||
const {getByTestId, getByText} = renderWithIntl(
|
||||
<SelectionSearchBar {...props}/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<SelectionSearchBar {...props}/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<SelectionSearchBar {...props}/>,
|
||||
);
|
||||
|
||||
const input = getByTestId('invite.search_bar_input');
|
||||
expect(input.props.value).toBe('test@example.com');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 (
|
||||
<View
|
||||
|
|
|
|||
91
app/screens/invite/selection_team_bar.test.tsx
Normal file
91
app/screens/invite/selection_team_bar.test.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {type ComponentProps} from 'react';
|
||||
import Share from 'react-native-share';
|
||||
|
||||
import TeamIcon from '@components/team_sidebar/team_list/team_item/team_icon';
|
||||
import {useServerDisplayName} from '@context/server';
|
||||
import {act, fireEvent, renderWithIntl, waitFor} from '@test/intl-test-helper';
|
||||
|
||||
import SelectionTeamBar from './selection_team_bar';
|
||||
|
||||
jest.mock('react-native-share', () => ({
|
||||
__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<ReturnType<typeof Share.open>>);
|
||||
});
|
||||
|
||||
function getBaseProps(): ComponentProps<typeof SelectionTeamBar> {
|
||||
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(
|
||||
<SelectionTeamBar {...props}/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<SelectionTeamBar {...props}/>,
|
||||
);
|
||||
|
||||
const shareButton = getByTestId('invite.share_link.button');
|
||||
act(() => {
|
||||
fireEvent.press(shareButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
expect(Share.open).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
243
app/screens/invite/summary.test.tsx
Normal file
243
app/screens/invite/summary.test.tsx
Normal file
|
|
@ -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<typeof Summary> {
|
||||
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(
|
||||
<Summary {...props}/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<Summary {...props}/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<Summary {...props}/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<Summary {...props}/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<Summary {...props}/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<Summary {...props}/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<Summary {...props}/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<Summary {...props}/>,
|
||||
);
|
||||
|
||||
const reports = getAllByTestId('invite.summary_report');
|
||||
expect(reports).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
111
app/screens/invite/summary_report.test.tsx
Normal file
111
app/screens/invite/summary_report.test.tsx
Normal file
|
|
@ -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<typeof SummaryReport> {
|
||||
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(
|
||||
<SummaryReport {...props}/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<SummaryReport {...props}/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<SummaryReport {...props}/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<SummaryReport {...props}/>,
|
||||
);
|
||||
|
||||
expect(getByText('Invited as a member')).toBeTruthy();
|
||||
expect(getByText('An invitation email has been sent')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -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}
|
||||
</Text>
|
||||
</View>
|
||||
{invites.map(({userId, reason}) => {
|
||||
const item = secureGetFromRecord(selectedIds, userId);
|
||||
{invites.map(({userId, email, reason}) => {
|
||||
const key = userId ?? email ?? '';
|
||||
const item = secureGetFromRecord(selectedIds, key);
|
||||
|
||||
return (
|
||||
<View
|
||||
key={userId}
|
||||
key={key}
|
||||
style={styles.item}
|
||||
>
|
||||
{typeof item === 'string' ? (
|
||||
|
|
|
|||
55
app/screens/invite/text_item.test.tsx
Normal file
55
app/screens/invite/text_item.test.tsx
Normal file
|
|
@ -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(
|
||||
<TextItem
|
||||
text='test@example.com'
|
||||
type={TextItemType.SEARCH_INVITE}
|
||||
testID='invite.text_item'
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByText('invite')).toBeTruthy();
|
||||
expect(getByText('test@example.com')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders search no results type correctly', () => {
|
||||
const {getByText} = renderWithIntl(
|
||||
<TextItem
|
||||
text='test@example.com'
|
||||
type={TextItemType.SEARCH_NO_RESULTS}
|
||||
testID='invite.text_item'
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByText('No one found matching')).toBeTruthy();
|
||||
expect(getByText('test@example.com')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders summary type correctly', () => {
|
||||
const {getByText} = renderWithIntl(
|
||||
<TextItem
|
||||
text='test@example.com'
|
||||
type={TextItemType.SUMMARY}
|
||||
testID='invite.text_item'
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByText('test@example.com')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -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 (
|
||||
<View
|
||||
style={[styles.item, search ? styles.search : {}]}
|
||||
style={[styles.item, search ? styles.search : undefined]}
|
||||
testID={`${testID}.${text}`}
|
||||
>
|
||||
{email && (
|
||||
|
|
@ -98,7 +97,7 @@ export default function TextItem({
|
|||
</Text>
|
||||
)}
|
||||
<Text
|
||||
style={[search ? styles.itemTerm : styles.itemText, {flex: 1}]}
|
||||
style={[search ? styles.itemTerm : styles.itemText, styles.flex]}
|
||||
numberOfLines={1}
|
||||
testID={`${testID}.text.${text}`}
|
||||
>
|
||||
|
|
|
|||
33
app/screens/invite/types.ts
Normal file
33
app/screens/invite/types.ts
Normal file
|
|
@ -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',
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
4
types/api/teams.d.ts
vendored
4
types/api/teams.d.ts
vendored
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in a new issue