Skip to content

Commit 5cd2363

Browse files
Add guest invites (#9288) (#9347)
* Add guest invites * Fix tests and address copilot feedback * Fix test * Add magic link changes (cherry picked from commit d76791e) Co-authored-by: Daniel Espino García <[email protected]>
1 parent 5037229 commit 5cd2363

25 files changed

+2795
-208
lines changed

app/actions/remote/team.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
addUserToTeam,
1515
addUsersToTeam,
1616
sendEmailInvitesToTeam,
17+
sendGuestEmailInvitesToTeam,
1718
fetchMyTeams,
1819
fetchMyTeam,
1920
fetchTeamById,
@@ -83,6 +84,7 @@ const mockClient = {
8384
addToTeam: jest.fn((id: string, userId: string) => ({id: userId + '-' + id, user_id: userId, team_id: id, roles: ''})),
8485
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})))),
8586
sendEmailInvitesToTeamGracefully: jest.fn((id: string, emails: string[]) => (emails.map((email) => ({email, error: undefined})))),
87+
sendGuestEmailInvitesToTeamGracefully: jest.fn(() => Promise.resolve<TeamInviteWithError[]>([{email: '[email protected]', error: {message: '', status_code: 0}}])),
8688
getRolesByNames: jest.fn((roles: string[]) => roles.map((r) => ({id: r, name: r} as Role))),
8789
getMyTeams: jest.fn(() => ([{id: teamId, name: 'team1'}])),
8890
getMyTeamMembers: jest.fn(() => ([{id: 'userid1-' + teamId, user_id: 'userid1', team_id: teamId, roles: ''}])),
@@ -223,6 +225,88 @@ describe('teamMember', () => {
223225
});
224226
});
225227

228+
describe('sendGuestEmailInvitesToTeam', () => {
229+
const emails = ['[email protected]', '[email protected]'];
230+
const channels = ['channel-id-1', 'channel-id-2'];
231+
const message = 'Welcome to the team!';
232+
233+
const throwFunc = () => {
234+
throw Error('error');
235+
};
236+
237+
beforeEach(() => {
238+
jest.clearAllMocks();
239+
});
240+
241+
it('should send guest email invites successfully with all parameters', async () => {
242+
const mockMembers: TeamInviteWithError[] = [
243+
{email: '[email protected]', error: {message: '', status_code: 0}},
244+
{email: '[email protected]', error: {message: '', status_code: 0}},
245+
];
246+
mockClient.sendGuestEmailInvitesToTeamGracefully.mockResolvedValueOnce(mockMembers);
247+
248+
const result = await sendGuestEmailInvitesToTeam(serverUrl, teamId, emails, channels, message);
249+
250+
expect(result).toBeDefined();
251+
expect(result.error).toBeUndefined();
252+
expect(result.members).toEqual(mockMembers);
253+
expect(mockClient.sendGuestEmailInvitesToTeamGracefully).toHaveBeenCalledWith(teamId, emails, channels, message, false);
254+
expect(mockClient.sendGuestEmailInvitesToTeamGracefully).toHaveBeenCalledTimes(1);
255+
});
256+
257+
it('should send guest email invites successfully without message parameter', async () => {
258+
const mockMembers: TeamInviteWithError[] = [
259+
{email: '[email protected]', error: {message: '', status_code: 0}},
260+
];
261+
mockClient.sendGuestEmailInvitesToTeamGracefully.mockResolvedValueOnce(mockMembers);
262+
263+
const result = await sendGuestEmailInvitesToTeam(serverUrl, teamId, ['[email protected]'], channels);
264+
265+
expect(result).toBeDefined();
266+
expect(result.error).toBeUndefined();
267+
expect(result.members).toEqual(mockMembers);
268+
expect(mockClient.sendGuestEmailInvitesToTeamGracefully).toHaveBeenCalledWith(teamId, ['[email protected]'], channels, '', false);
269+
expect(mockClient.sendGuestEmailInvitesToTeamGracefully).toHaveBeenCalledTimes(1);
270+
});
271+
272+
it('should handle client error', async () => {
273+
const clientError = new Error('Client error');
274+
mockClient.sendGuestEmailInvitesToTeamGracefully.mockRejectedValueOnce(clientError);
275+
276+
const result = await sendGuestEmailInvitesToTeam(serverUrl, teamId, emails, channels, message);
277+
278+
expect(result).toBeDefined();
279+
expect(result.error).toBeDefined();
280+
expect(result.members).toEqual([]);
281+
expect(mockClient.sendGuestEmailInvitesToTeamGracefully).toHaveBeenCalledWith(teamId, emails, channels, message, false);
282+
});
283+
284+
it('should handle network manager error', async () => {
285+
jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc);
286+
287+
const result = await sendGuestEmailInvitesToTeam(serverUrl, teamId, emails, channels, message);
288+
289+
expect(result).toBeDefined();
290+
expect(result.error).toBeDefined();
291+
expect(result.members).toEqual([]);
292+
});
293+
294+
it('should send guest email invites with guest magic link', async () => {
295+
const mockMembers: TeamInviteWithError[] = [
296+
{email: '[email protected]', error: {message: '', status_code: 0}},
297+
];
298+
mockClient.sendGuestEmailInvitesToTeamGracefully.mockResolvedValueOnce(mockMembers);
299+
300+
const result = await sendGuestEmailInvitesToTeam(serverUrl, teamId, emails, channels, message, true);
301+
302+
expect(result).toBeDefined();
303+
expect(result.error).toBeUndefined();
304+
expect(result.members).toEqual(mockMembers);
305+
expect(mockClient.sendGuestEmailInvitesToTeamGracefully).toHaveBeenCalledWith(teamId, emails, channels, message, true);
306+
expect(mockClient.sendGuestEmailInvitesToTeamGracefully).toHaveBeenCalledTimes(1);
307+
});
308+
});
309+
226310
describe('teams', () => {
227311
it('fetchMyTeams - handle not found database', async () => {
228312
const result = await fetchMyTeams('foo') as {error: unknown};

app/actions/remote/team.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ export async function addUserToTeam(serverUrl: string, teamId: string, userId: s
109109
}
110110
}
111111

112-
export async function addUsersToTeam(serverUrl: string, teamId: string, userIds: string[], fetchOnly = false) {
112+
export async function addUsersToTeam(serverUrl: string, teamId: string, userIds: string[], fetchOnly = false): Promise<{members: TeamMemberWithError[]; error?: unknown}> {
113113
try {
114114
const client = NetworkManager.getClient(serverUrl);
115115
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
@@ -142,20 +142,33 @@ export async function addUsersToTeam(serverUrl: string, teamId: string, userIds:
142142
}
143143

144144
forceLogoutIfNecessary(serverUrl, error);
145-
return {error};
145+
return {members: [], error};
146146
}
147147
}
148148

149-
export async function sendEmailInvitesToTeam(serverUrl: string, teamId: string, emails: string[]) {
149+
export async function sendEmailInvitesToTeam(serverUrl: string, teamId: string, emails: string[]): Promise<{members: TeamInviteWithError[]; error?: unknown}> {
150150
try {
151151
const client = NetworkManager.getClient(serverUrl);
152152
const members = await client.sendEmailInvitesToTeamGracefully(teamId, emails);
153153

154-
return {members};
154+
return {members, error: undefined};
155155
} catch (error) {
156156
logDebug('error on sendEmailInvitesToTeam', getFullErrorMessage(error));
157157
forceLogoutIfNecessary(serverUrl, error);
158-
return {error};
158+
return {members: [], error};
159+
}
160+
}
161+
162+
export async function sendGuestEmailInvitesToTeam(serverUrl: string, teamId: string, emails: string[], channels: string[], message = '', guestMagicLink = false): Promise<{members: TeamInviteWithError[]; error?: unknown}> {
163+
try {
164+
const client = NetworkManager.getClient(serverUrl);
165+
const members = await client.sendGuestEmailInvitesToTeamGracefully(teamId, emails, channels, message, guestMagicLink);
166+
167+
return {members, error: undefined};
168+
} catch (error) {
169+
logDebug('error on sendGuestEmailInvitesToTeam', getFullErrorMessage(error));
170+
forceLogoutIfNecessary(serverUrl, error);
171+
return {members: [], error};
159172
}
160173
}
161174

@@ -483,7 +496,7 @@ export async function handleKickFromTeam(serverUrl: string, teamId: string) {
483496
}
484497
}
485498

486-
export async function getTeamMembersByIds(serverUrl: string, teamId: string, userIds: string[], fetchOnly?: boolean) {
499+
export async function getTeamMembersByIds(serverUrl: string, teamId: string, userIds: string[], fetchOnly?: boolean): Promise<{members: TeamMembership[]; error?: unknown}> {
487500
try {
488501
const client = NetworkManager.getClient(serverUrl);
489502
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
@@ -505,7 +518,7 @@ export async function getTeamMembersByIds(serverUrl: string, teamId: string, use
505518
} catch (error) {
506519
logDebug('error on getTeamMembersByIds', getFullErrorMessage(error));
507520
forceLogoutIfNecessary(serverUrl, error);
508-
return {error};
521+
return {members: [], error};
509522
}
510523
}
511524

app/client/rest/teams.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,78 @@ describe('ClientTeams', () => {
194194
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
195195
});
196196

197+
describe('sendGuestEmailInvitesToTeamGracefully', () => {
198+
it('should send guest email invites with all params', async () => {
199+
const teamId = 'team1';
200+
const emails = ['[email protected]', '[email protected]'];
201+
const channels = ['channel1', 'channel2'];
202+
const message = 'Welcome to the team!';
203+
const params = {graceful: true};
204+
const expectedUrl = `${client.getTeamRoute(teamId)}/invite-guests/email${buildQueryString(params)}`;
205+
const expectedOptions = {method: 'post', body: {message, emails, channels}};
206+
const mockResponse: TeamInviteWithError[] = [
207+
{email: '[email protected]', error: {message: '', status_code: 0}},
208+
{email: '[email protected]', error: {message: '', status_code: 0}},
209+
];
210+
211+
jest.mocked(client.doFetch).mockResolvedValueOnce(mockResponse as any);
212+
213+
const result = await client.sendGuestEmailInvitesToTeamGracefully(teamId, emails, channels, message);
214+
215+
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
216+
expect(result).toEqual(mockResponse);
217+
});
218+
219+
it('should send guest email invites with default message', async () => {
220+
const teamId = 'team1';
221+
const emails = ['[email protected]'];
222+
const channels = ['channel1'];
223+
const expectedUrl = `${client.getTeamRoute(teamId)}/invite-guests/email${buildQueryString({graceful: true})}`;
224+
const expectedOptions = {method: 'post', body: {message: '', emails, channels}};
225+
const mockResponse: TeamInviteWithError[] = [
226+
{email: '[email protected]', error: {message: '', status_code: 0}},
227+
];
228+
229+
jest.mocked(client.doFetch).mockResolvedValueOnce(mockResponse as any);
230+
231+
const result = await client.sendGuestEmailInvitesToTeamGracefully(teamId, emails, channels);
232+
233+
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
234+
expect(result).toEqual(mockResponse);
235+
});
236+
237+
it('should handle error when sending guest email invites', async () => {
238+
const teamId = 'team1';
239+
const emails = ['[email protected]'];
240+
const channels = ['channel1'];
241+
const message = 'Test message';
242+
243+
jest.mocked(client.doFetch).mockRejectedValueOnce(new Error('Network error'));
244+
245+
await expect(client.sendGuestEmailInvitesToTeamGracefully(teamId, emails, channels, message)).rejects.toThrow('Network error');
246+
});
247+
248+
it('should send guest email invites with guest magic link', async () => {
249+
const teamId = 'team1';
250+
const emails = ['[email protected]'];
251+
const channels = ['channel1'];
252+
const message = 'Test message';
253+
const guestMagicLink = true;
254+
const expectedUrl = `${client.getTeamRoute(teamId)}/invite-guests/email${buildQueryString({graceful: true, guest_magic_link: true})}`;
255+
const expectedOptions = {method: 'post', body: {message, emails, channels}};
256+
const mockResponse: TeamInviteWithError[] = [
257+
{email: '[email protected]', error: {message: '', status_code: 0}},
258+
];
259+
260+
jest.mocked(client.doFetch).mockResolvedValueOnce(mockResponse as any);
261+
262+
const result = await client.sendGuestEmailInvitesToTeamGracefully(teamId, emails, channels, message, guestMagicLink);
263+
264+
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
265+
expect(result).toEqual(mockResponse);
266+
});
267+
});
268+
197269
test('joinTeam', async () => {
198270
const inviteId = 'invite1';
199271
const query = buildQueryString({invite_id: inviteId});

app/client/rest/teams.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {buildQueryString} from '@utils/helpers';
66
import {PER_PAGE_DEFAULT} from './constants';
77

88
import type ClientBase from './base';
9+
import type {FirstArgument} from '@typings/utils/utils';
910

1011
export interface ClientTeamsMix {
1112
createTeam: (team: Team) => Promise<Team>;
@@ -24,6 +25,7 @@ export interface ClientTeamsMix {
2425
addToTeam: (teamId: string, userId: string) => Promise<TeamMembership>;
2526
addUsersToTeamGracefully: (teamId: string, userIds: string[]) => Promise<TeamMemberWithError[]>;
2627
sendEmailInvitesToTeamGracefully: (teamId: string, emails: string[]) => Promise<TeamInviteWithError[]>;
28+
sendGuestEmailInvitesToTeamGracefully: (teamId: string, emails: string[], channels: string[], message?: string, magicLink?: boolean) => Promise<TeamInviteWithError[]>;
2729
joinTeam: (inviteId: string) => Promise<TeamMembership>;
2830
removeFromTeam: (teamId: string, userId: string) => Promise<any>;
2931
getTeamStats: (teamId: string) => Promise<any>;
@@ -147,6 +149,19 @@ const ClientTeams = <TBase extends Constructor<ClientBase>>(superclass: TBase) =
147149
);
148150
};
149151

152+
sendGuestEmailInvitesToTeamGracefully = (teamId: string, emails: string[], channels: string[], message = '', guestMagicLink = false) => {
153+
const params: FirstArgument<typeof buildQueryString> = {
154+
graceful: true,
155+
};
156+
if (guestMagicLink) {
157+
params.guest_magic_link = true;
158+
}
159+
return this.doFetch(
160+
`${this.getTeamRoute(teamId)}/invite-guests/email${buildQueryString(params)}`,
161+
{method: 'post', body: {message, emails, channels}},
162+
);
163+
};
164+
150165
joinTeam = async (inviteId: string) => {
151166
const query = buildQueryString({invite_id: inviteId});
152167
return this.doFetch(
@@ -170,7 +185,7 @@ const ClientTeams = <TBase extends Constructor<ClientBase>>(superclass: TBase) =
170185
};
171186

172187
getTeamIconUrl = (teamId: string, lastTeamIconUpdate: number) => {
173-
const params: any = {};
188+
const params: FirstArgument<typeof buildQueryString> = {};
174189
if (lastTeamIconUpdate) {
175190
params._ = lastTeamIconUpdate;
176191
}

app/components/option_item/index.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ export type OptionItemProps = {
117117
icon?: string;
118118
iconColor?: string;
119119
info?: string | UserChipData;
120+
isInfoDestructive?: boolean;
120121
inline?: boolean;
121122
label: string;
122123
onRemove?: () => void;
@@ -138,6 +139,7 @@ const OptionItem = ({
138139
icon,
139140
iconColor,
140141
info,
142+
isInfoDestructive = false,
141143
inline = false,
142144
label,
143145
onRemove,
@@ -274,7 +276,7 @@ const OptionItem = ({
274276
} else if (info) {
275277
infoComponent = (
276278
<Text
277-
style={[styles.info, !actionComponent && styles.iconContainer, destructive && {color: theme.dndIndicator}]}
279+
style={[styles.info, !actionComponent && styles.iconContainer, (destructive || isInfoDestructive) && {color: theme.dndIndicator}]}
278280
testID={`${testID}.info`}
279281
numberOfLines={1}
280282
>

app/components/option_item/option_item.test.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,4 +375,16 @@ describe('OptionItem', () => {
375375
});
376376
expect(props.action).not.toHaveBeenCalled();
377377
});
378+
379+
it('should show destructive info styling when isInfoDestructive is true', () => {
380+
const props = getBaseProps();
381+
props.info = 'Test info';
382+
props.isInfoDestructive = true;
383+
props.destructive = false;
384+
385+
const {getByText} = renderWithIntlAndTheme(<OptionItem {...props}/>);
386+
387+
const info = getByText('Test info');
388+
expect(info).toHaveStyle({color: Preferences.THEMES.denim.dndIndicator});
389+
});
378390
});

0 commit comments

Comments
 (0)