diff --git a/i18n/en-US.properties b/i18n/en-US.properties index ab057cdb44..a4b9ee8fed 100644 --- a/i18n/en-US.properties +++ b/i18n/en-US.properties @@ -149,6 +149,10 @@ be.contentSharing.noAccessError = You do not have access to this item. # Message that appears when the item for the ContentSharing Element cannot be found. be.contentSharing.notFoundError = Could not find shared link for this item. # Message that appears when collaborators cannot be added to the shared link in the ContentSharing Element. +be.contentSharing.sendInvitationsError = {count, plural, one {Failed to invite a collaborator.} other {Failed to invite {count} collaborators.}} +# Message that appears when collaborators were added to the shared link in the ContentSharing Element. +be.contentSharing.sendInvitationsSuccess = {count, plural, one {Successfully invited a collaborator.} other {Successfully invited {count} collaborators.}} +# Message that appears when collaborators cannot be added to the shared link in the ContentSharing Element. be.contentSharing.sendInvitesError = Could not send invites. # Message that appears when collaborators were added to the shared link in the ContentSharing Element. be.contentSharing.sendInvitesSuccess = Successfully invited collaborators. diff --git a/src/elements/content-sharing/hooks/__tests__/useSharingService.test.ts b/src/elements/content-sharing/hooks/__tests__/useSharingService.test.ts index 63d24a84e5..b9e203878a 100644 --- a/src/elements/content-sharing/hooks/__tests__/useSharingService.test.ts +++ b/src/elements/content-sharing/hooks/__tests__/useSharingService.test.ts @@ -10,6 +10,13 @@ jest.mock('../../utils/convertItemResponse'); jest.mock('../../utils/convertCollaborators'); jest.mock('../../sharingService'); jest.mock('../useInvites'); +const mockFormatMessage = jest.fn(({ defaultMessage }) => defaultMessage); +jest.mock('react-intl', () => ({ + ...jest.requireActual('react-intl'), + useIntl: () => ({ + formatMessage: mockFormatMessage, + }), +})); const mockApi = { getFileAPI: jest.fn(), @@ -229,7 +236,7 @@ describe('elements/content-sharing/hooks/useSharingService', () => { }); }); - describe('sendInvitations', () => { + describe('handleSendInvitations', () => { const mockCollaborators = [{ id: 'collab-1', email: 'existing@example.com', type: 'user' }]; const mockAvatarUrlMap = { 'user-1': 'https://example.com/avatar.jpg' }; const mockCurrentUserId = 'current-user-123'; @@ -293,5 +300,90 @@ describe('elements/content-sharing/hooks/useSharingService', () => { expect(convertCollabsRequest).toHaveBeenCalledWith(mockCollabRequest, mockCollaborators); }); + + describe('sendInvitations notification rendering', () => { + const mockContacts = [ + { id: 'user-1', email: 'user1@test.com', type: 'user' }, + { id: 'user-2', email: 'user2@test.com', type: 'user' }, + { id: 'user-3', email: 'user3@test.com', type: 'user' }, + ]; + + test('should return success notification when all contacts are successfully invited', async () => { + const mockResult = [ + { id: 'result-1', email: 'user1@test.com' }, + { id: 'result-2', email: 'user2@test.com' }, + { id: 'result-3', email: 'user3@test.com' }, + ]; + mockSendInvitations.mockResolvedValue(mockResult); + const { result } = renderHookWithProps(); + + const sendInvitationsResult = await result.current.sharingService.sendInvitations({ + contacts: mockContacts, + role: 'editor', + }); + + expect(mockFormatMessage).toHaveBeenCalledWith( + expect.objectContaining({ id: 'be.contentSharing.sendInvitationsSuccess' }), + { count: 3 }, // Counts of successfully invited collaborators + ); + expect(sendInvitationsResult.messages[0].type).toEqual('success'); + }); + + test('should return correct notification when some invitations are invited', async () => { + const mockResult = [ + { id: 'result-1', email: 'user1@test.com' }, + { id: 'result-2', email: 'user2@test.com' }, + ]; + mockSendInvitations.mockResolvedValue(mockResult); + const { result } = renderHookWithProps(); + + const sendInvitationsResult = await result.current.sharingService.sendInvitations({ + contacts: mockContacts, + role: 'editor', + }); + + expect(mockFormatMessage).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ id: 'be.contentSharing.sendInvitationsError' }), + { count: 1 }, // Counts of invitations not sent + ); + expect(mockFormatMessage).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ id: 'be.contentSharing.sendInvitationsSuccess' }), + { count: 2 }, // Counts of successfully invited collaborators + ); + expect(sendInvitationsResult.messages[0].type).toEqual('error'); + expect(sendInvitationsResult.messages[1].type).toEqual('success'); + }); + + test('should return error notification when no contacts are successfully invited', async () => { + const mockResult = []; + mockSendInvitations.mockResolvedValue(mockResult); + const { result } = renderHookWithProps(); + + const sendInvitationsResult = await result.current.sharingService.sendInvitations({ + contacts: mockContacts, + role: 'editor', + }); + + expect(mockFormatMessage).toHaveBeenCalledWith( + expect.objectContaining({ id: 'be.contentSharing.sendInvitationsError' }), + { count: 3 }, // Counts of invitations not sent + ); + expect(sendInvitationsResult.messages[0].type).toEqual('error'); + }); + + test('should return null when no result is returned from handleSendInvitations', async () => { + mockSendInvitations.mockResolvedValue(null); + const { result } = renderHookWithProps(); + + const sendInvitationsResult = await result.current.sharingService.sendInvitations({ + contacts: mockContacts, + role: 'editor', + }); + + expect(sendInvitationsResult).toBeNull(); + }); + }); }); }); diff --git a/src/elements/content-sharing/hooks/useContactService.ts b/src/elements/content-sharing/hooks/useContactService.ts index 52b297376c..5cb432fcf0 100644 --- a/src/elements/content-sharing/hooks/useContactService.ts +++ b/src/elements/content-sharing/hooks/useContactService.ts @@ -25,7 +25,9 @@ export const useContactService = (api, itemId, currentUserId) => { const getContactsAvatarUrls = React.useCallback( async contacts => { - if (!contacts || contacts.length === 0) return Promise.resolve({}); + if (!contacts || contacts.length === 0) { + return Promise.resolve({}); + } const collaborators = contacts.map(contact => ({ accessible_by: { diff --git a/src/elements/content-sharing/hooks/useSharingService.ts b/src/elements/content-sharing/hooks/useSharingService.ts index 1e9a74df90..519b85c142 100644 --- a/src/elements/content-sharing/hooks/useSharingService.ts +++ b/src/elements/content-sharing/hooks/useSharingService.ts @@ -1,10 +1,13 @@ import * as React from 'react'; +import { useIntl } from 'react-intl'; import { TYPE_FILE, TYPE_FOLDER } from '../../../constants'; import { convertItemResponse, convertCollab, convertCollabsRequest } from '../utils'; import { createSharingService } from '../sharingService'; import useInvites from './useInvites'; +import messages from '../messages'; + export const useSharingService = ({ api, avatarUrlMap, @@ -19,6 +22,8 @@ export const useSharingService = ({ setItem, setSharedLink, }) => { + const { formatMessage } = useIntl(); + // itemApiInstance should only be called once or the API will cause an issue where it gets cancelled const itemApiInstance = React.useMemo(() => { if (!item || !sharedLink) { @@ -78,23 +83,51 @@ export const useSharingService = ({ const ownerEmailDomain = ownerEmail && /@/.test(ownerEmail) ? ownerEmail.split('@')[1] : null; setCollaborators(prevList => { const newCollab = convertCollab({ + avatarUrlMap, collab: response, currentUserId, isCurrentUserOwner: currentUserId === ownerId, ownerEmailDomain, - avatarUrlMap, }); return newCollab ? [...prevList, newCollab] : prevList; }); }; - const sendInvitations = useInvites(api, itemId, itemType, { + const handleSendInvitations = useInvites(api, itemId, itemType, { collaborators, handleSuccess, isContentSharingV2Enabled: true, transformRequest: data => convertCollabsRequest(data, collaborators), }); + const sendInvitations = (...request) => { + return handleSendInvitations(...request).then(response => { + const { contacts: collabRequest } = request[0]; + if (!response || !collabRequest || collabRequest.length === 0) { + return null; + } + + const successCount = response.length; + const errorCount = collabRequest.length - successCount; + + const notification = []; + if (errorCount > 0) { + notification.push({ + text: formatMessage(messages.sendInvitationsError, { count: errorCount }), + type: 'error', + }); + } + if (successCount > 0) { + notification.push({ + text: formatMessage(messages.sendInvitationsSuccess, { count: successCount }), + type: 'success', + }); + } + + return notification.length > 0 ? { messages: notification } : null; + }); + }; + return { sharingService: { ...sharingService, sendInvitations } }; }; diff --git a/src/elements/content-sharing/messages.js b/src/elements/content-sharing/messages.js index 4e2d5a1273..325043856a 100644 --- a/src/elements/content-sharing/messages.js +++ b/src/elements/content-sharing/messages.js @@ -59,6 +59,20 @@ const messages = defineMessages({ 'Message that appears when collaborators were added to the shared link in the ContentSharing Element.', id: 'be.contentSharing.sendInvitesSuccess', }, + sendInvitationsError: { + defaultMessage: + '{count, plural, one {Failed to invite a collaborator.} other {Failed to invite {count} collaborators.}}', + description: + 'Message that appears when collaborators cannot be added to the shared link in the ContentSharing Element.', + id: 'be.contentSharing.sendInvitationsError', + }, + sendInvitationsSuccess: { + defaultMessage: + '{count, plural, one {Successfully invited a collaborator.} other {Successfully invited {count} collaborators.}}', + description: + 'Message that appears when collaborators were added to the shared link in the ContentSharing Element.', + id: 'be.contentSharing.sendInvitationsSuccess', + }, groupContactLabel: { defaultMessage: 'Group', description: 'Display text for a Group contact type', diff --git a/src/elements/content-sharing/utils/__tests__/convertCollaborators.test.ts b/src/elements/content-sharing/utils/__tests__/convertCollaborators.test.ts index eda51b8bc3..d42bcf8d1f 100644 --- a/src/elements/content-sharing/utils/__tests__/convertCollaborators.test.ts +++ b/src/elements/content-sharing/utils/__tests__/convertCollaborators.test.ts @@ -64,11 +64,11 @@ describe('convertCollaborators', () => { describe('convertCollab', () => { test('should convert a valid collaboration to Collaborator format', () => { const result = convertCollab({ + avatarUrlMap: mockAvatarUrlMap, collab: mockCollaborations[1], currentUserId: mockOwnerId, isCurrentUserOwner: false, ownerEmailDomain, - avatarUrlMap: mockAvatarUrlMap, }); expect(result).toEqual({ @@ -89,11 +89,11 @@ describe('convertCollaborators', () => { test('should return null for collaboration with non-accepted status', () => { const result = convertCollab({ + avatarUrlMap: mockAvatarUrlMap, collab: mockCollaborations[3], currentUserId: mockOwnerId, isCurrentUserOwner: false, ownerEmailDomain, - avatarUrlMap: mockAvatarUrlMap, }); expect(result).toBeNull(); @@ -101,11 +101,11 @@ describe('convertCollaborators', () => { test.each([undefined, null])('should return null for %s collaboration', collab => { const result = convertCollab({ + avatarUrlMap: mockAvatarUrlMap, collab, currentUserId: mockOwnerId, isCurrentUserOwner: false, ownerEmailDomain, - avatarUrlMap: mockAvatarUrlMap, }); expect(result).toBeNull(); @@ -113,11 +113,11 @@ describe('convertCollaborators', () => { test('should identify current user correctly', () => { const result = convertCollab({ + avatarUrlMap: mockAvatarUrlMap, collab: mockCollaborations[0], currentUserId: mockOwnerId, isCurrentUserOwner: true, ownerEmailDomain, - avatarUrlMap: mockAvatarUrlMap, }); expect(result).toEqual({ @@ -137,11 +137,11 @@ describe('convertCollaborators', () => { test('should identify external user correctly', () => { const result = convertCollab({ + avatarUrlMap: mockAvatarUrlMap, collab: mockCollaborations[2], currentUserId: mockOwnerId, isCurrentUserOwner: false, ownerEmailDomain, - avatarUrlMap: mockAvatarUrlMap, }); expect(result.isExternal).toBe(true); @@ -151,11 +151,11 @@ describe('convertCollaborators', () => { 'should handle %s avatar URL map', avatarUrlMap => { const result = convertCollab({ + avatarUrlMap, collab: mockCollaborations[1], currentUserId: mockOwnerId, isCurrentUserOwner: false, ownerEmailDomain, - avatarUrlMap, }); expect(result.avatarUrl).toBeUndefined(); @@ -170,11 +170,11 @@ describe('convertCollaborators', () => { }; const result = convertCollab({ + avatarUrlMap: mockAvatarUrlMap, collab: collabWithoutExpiration, currentUserId: mockOwnerId, isCurrentUserOwner: false, ownerEmailDomain, - avatarUrlMap: mockAvatarUrlMap, }); expect(result.expiresAt).toBeNull(); @@ -305,6 +305,45 @@ describe('convertCollaborators', () => { }); }); + test('should convert collab request with users without a type', () => { + const mockCollabRequest = { + role: 'editor', + contacts: [ + { + id: 'user1', + email: 'user1@test.com', + type: 'user', + }, + { + id: 'user2', + email: 'external@test.com', + }, + ], + }; + + const result = convertCollabsRequest(mockCollabRequest, null); + + expect(result).toEqual({ + groups: [], + users: [ + { + accessible_by: { + login: 'user1@test.com', + type: 'user', + }, + role: 'editor', + }, + { + accessible_by: { + login: 'external@test.com', + type: 'user', + }, + role: 'editor', + }, + ], + }); + }); + test('should handle empty contacts array', () => { const emptyCollabRequest = { role: 'editor', diff --git a/src/elements/content-sharing/utils/convertCollaborators.ts b/src/elements/content-sharing/utils/convertCollaborators.ts index b2d925d3a6..3b13b7e756 100644 --- a/src/elements/content-sharing/utils/convertCollaborators.ts +++ b/src/elements/content-sharing/utils/convertCollaborators.ts @@ -15,11 +15,11 @@ export interface ConvertCollabProps { } export const convertCollab = ({ + avatarUrlMap, collab, currentUserId, isCurrentUserOwner, ownerEmailDomain, - avatarUrlMap, }: ConvertCollabProps): Collaborator | null => { if (!collab || collab.status !== STATUS_ACCEPTED) return null; @@ -76,7 +76,7 @@ export const convertCollabsResponse = ( }; return [itemOwner, ...entries].flatMap(collab => { - const converted = convertCollab({ collab, currentUserId, isCurrentUserOwner, ownerEmailDomain, avatarUrlMap }); + const converted = convertCollab({ avatarUrlMap, collab, currentUserId, isCurrentUserOwner, ownerEmailDomain }); return converted ? [converted] : []; }); }; @@ -97,16 +97,6 @@ export const convertCollabsRequest = (collabRequest, existingCollaboratorsList) return; } - if (contact.type === COLLAB_USER_TYPE) { - users.push({ - accessible_by: { - login: contact.email, - type: COLLAB_USER_TYPE, - }, - role, - }); - } - if (contact.type === COLLAB_GROUP_TYPE) { groups.push({ accessible_by: { @@ -115,6 +105,14 @@ export const convertCollabsRequest = (collabRequest, existingCollaboratorsList) }, role, }); + } else { + users.push({ + accessible_by: { + login: contact.email, + type: COLLAB_USER_TYPE, + }, + role, + }); } });