diff --git a/src/elements/content-sharing/ContentSharingV2.tsx b/src/elements/content-sharing/ContentSharingV2.tsx index a607399de9..1acf69a643 100644 --- a/src/elements/content-sharing/ContentSharingV2.tsx +++ b/src/elements/content-sharing/ContentSharingV2.tsx @@ -9,7 +9,7 @@ import Internationalize from '../common/Internationalize'; import Providers from '../common/Providers'; import { withBlueprintModernization } from '../common/withBlueprintModernization'; import { fetchAvatars, fetchCollaborators, fetchCurrentUser, fetchItem } from './apis'; -import { useSharingService } from './hooks/useSharingService'; +import { useContactService, useSharingService } from './hooks'; import { convertCollabsResponse, convertItemResponse } from './utils'; import type { Collaborations, ItemType, StringMap } from '../../common/types/core'; @@ -61,6 +61,7 @@ function ContentSharingV2({ setItem, setSharedLink, }); + const { contactService } = useContactService(api, itemID, currentUser?.id); // Handle successful GET requests to /files or /folders const handleGetItemSuccess = React.useCallback(itemData => { @@ -165,6 +166,7 @@ function ContentSharingV2({ config={config} collaborationRoles={collaborationRoles} collaborators={collaborators} + contactService={contactService} currentUser={currentUser} item={item} sharedLink={sharedLink} diff --git a/src/elements/content-sharing/__tests__/useContacts.test.js b/src/elements/content-sharing/__tests__/useContacts.test.js index 2e88f268e4..6a5790dde4 100644 --- a/src/elements/content-sharing/__tests__/useContacts.test.js +++ b/src/elements/content-sharing/__tests__/useContacts.test.js @@ -34,6 +34,7 @@ function FakeComponent({ const [getContacts, setGetContacts] = React.useState(null); const updatedGetContactsFn = useContacts(api, MOCK_ITEM_ID, { + currentUserId: '123', handleSuccess, handleError, transformGroups, diff --git a/src/elements/content-sharing/hooks/__tests__/useContactService.test.ts b/src/elements/content-sharing/hooks/__tests__/useContactService.test.ts new file mode 100644 index 0000000000..9ce3382d4c --- /dev/null +++ b/src/elements/content-sharing/hooks/__tests__/useContactService.test.ts @@ -0,0 +1,65 @@ +import { renderHook } from '@testing-library/react'; + +import { convertGroupContactsResponse, convertUserContactsResponse } from '../../utils'; +import { useContactService } from '../useContactService'; +import useContacts from '../useContacts'; + +jest.mock('../useContacts'); +jest.mock('../../utils'); + +const mockApi = { + getMarkerBasedUsersAPI: jest.fn(), + getMarkerBasedGroupsAPI: jest.fn(), +}; +const mockItemID = '123456789'; +const mockCurrentUserID = '123'; +const mockGetContacts = jest.fn(); + +describe('elements/content-sharing/hooks/useContactService', () => { + beforeEach(() => { + (useContacts as jest.Mock).mockReturnValue(mockGetContacts); + (convertGroupContactsResponse as jest.Mock).mockReturnValue([]); + (convertUserContactsResponse as jest.Mock).mockReturnValue([]); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should return contactService with getContacts function', () => { + const { result } = renderHook(() => useContactService(mockApi, mockItemID, mockCurrentUserID)); + + expect(useContacts).toHaveBeenCalledWith(mockApi, mockItemID, { + currentUserId: mockCurrentUserID, + isContentSharingV2Enabled: true, + transformUsers: expect.any(Function), + transformGroups: expect.any(Function), + }); + expect(result.current.contactService).toEqual({ + getContacts: mockGetContacts, + }); + }); + + test('should pass transform functions that call correct conversion functions with params', () => { + const mockTransformedUsers = [{ id: 'user1', email: 'user1@test.com' }]; + const mockTransformedGroups = [{ id: 'group1', name: 'Test Group' }]; + const mockUserData = { entries: mockTransformedUsers }; + const mockGroupData = { entries: mockTransformedGroups }; + + (convertUserContactsResponse as jest.Mock).mockReturnValue(mockTransformedUsers); + (convertGroupContactsResponse as jest.Mock).mockReturnValue(mockTransformedGroups); + + renderHook(() => useContactService(mockApi, mockItemID, mockCurrentUserID)); + + // Get the transform functions that were passed to useContacts + const transformUsersFn = useContacts.mock.calls[0][2].transformUsers; + const transformGroupsFn = useContacts.mock.calls[0][2].transformGroups; + const resultUsers = transformUsersFn(mockUserData); + const resultGroups = transformGroupsFn(mockGroupData); + + expect(convertUserContactsResponse as jest.Mock).toHaveBeenCalledWith(mockUserData, mockCurrentUserID); + expect(convertGroupContactsResponse as jest.Mock).toHaveBeenCalledWith(mockGroupData); + expect(resultUsers).toBe(mockTransformedUsers); + expect(resultGroups).toBe(mockTransformedGroups); + }); +}); diff --git a/src/elements/content-sharing/hooks/index.ts b/src/elements/content-sharing/hooks/index.ts new file mode 100644 index 0000000000..6b4ba31338 --- /dev/null +++ b/src/elements/content-sharing/hooks/index.ts @@ -0,0 +1,2 @@ +export { useContactService } from './useContactService'; +export { useSharingService } from './useSharingService'; diff --git a/src/elements/content-sharing/hooks/useContactService.ts b/src/elements/content-sharing/hooks/useContactService.ts new file mode 100644 index 0000000000..589c0547d8 --- /dev/null +++ b/src/elements/content-sharing/hooks/useContactService.ts @@ -0,0 +1,13 @@ +import { convertGroupContactsResponse, convertUserContactsResponse } from '../utils'; +import useContacts from './useContacts'; + +export const useContactService = (api, itemId, currentUserId) => { + const getContacts = useContacts(api, itemId, { + currentUserId, + isContentSharingV2Enabled: true, + transformUsers: data => convertUserContactsResponse(data, currentUserId), + transformGroups: data => convertGroupContactsResponse(data), + }); + + return { contactService: { getContacts } }; +}; diff --git a/src/elements/content-sharing/hooks/useContacts.js b/src/elements/content-sharing/hooks/useContacts.js index 24c016b02f..b6436f88b4 100644 --- a/src/elements/content-sharing/hooks/useContacts.js +++ b/src/elements/content-sharing/hooks/useContacts.js @@ -17,10 +17,19 @@ import type { ContentSharingHooksOptions, GetContactsFnType } from '../types'; */ function useContacts(api: API, itemID: string, options: ContentSharingHooksOptions): GetContactsFnType | null { const [getContacts, setGetContacts] = React.useState(null); - const { handleSuccess = noop, handleError = noop, transformGroups, transformUsers } = options; + const { + currentUserId, + handleSuccess = noop, + handleError = noop, + isContentSharingV2Enabled, + transformGroups, + transformUsers, + } = options; React.useEffect(() => { - if (getContacts) return; + if (getContacts || (isContentSharingV2Enabled && !currentUserId)) { + return; + } const resolveAPICall = ( resolve: (result: Array) => void, @@ -60,7 +69,17 @@ function useContacts(api: API, itemID: string, options: ContentSharingHooksOptio return Promise.all([getUsers, getGroups]).then(contactArrays => [...contactArrays[0], ...contactArrays[1]]); }; setGetContacts(updatedGetContactsFn); - }, [api, getContacts, handleError, handleSuccess, itemID, transformGroups, transformUsers]); + }, [ + api, + currentUserId, + getContacts, + handleError, + handleSuccess, + isContentSharingV2Enabled, + itemID, + transformGroups, + transformUsers, + ]); return getContacts; } diff --git a/src/elements/content-sharing/types.js b/src/elements/content-sharing/types.js index 3ccf79861c..4a375dbc1d 100644 --- a/src/elements/content-sharing/types.js +++ b/src/elements/content-sharing/types.js @@ -92,12 +92,14 @@ export type ContentSharingItemAPIResponse = { }; export type ContentSharingHooksOptions = { + currentUserId?: string, handleError?: Function, handleRemoveSharedLinkError?: Function, handleRemoveSharedLinkSuccess?: Function, handleSuccess?: Function, handleUpdateSharedLinkError?: Function, handleUpdateSharedLinkSuccess?: Function, + isContentSharingV2Enabled?: boolean, setIsLoading?: Function, transformAccess?: Function, transformGroups?: Function, diff --git a/src/elements/content-sharing/utils/__tests__/convertContactServiceData.test.ts b/src/elements/content-sharing/utils/__tests__/convertContactServiceData.test.ts new file mode 100644 index 0000000000..ba534f57fa --- /dev/null +++ b/src/elements/content-sharing/utils/__tests__/convertContactServiceData.test.ts @@ -0,0 +1,332 @@ +import { STATUS_INACTIVE } from '../../../../constants'; +import { convertUserContactsResponse, convertGroupContactsResponse } from '../convertContactServiceData'; + +const mockCurrentUserId = '123'; + +describe('elements/content-sharing/utils/convertContactServiceData', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('convertUserContactsResponse', () => { + describe('basic conversion', () => { + test('should return empty array when entries is empty', () => { + const contactsApiData = { entries: [] }; + const result = convertUserContactsResponse(contactsApiData, mockCurrentUserId); + expect(result).toEqual([]); + }); + + test('should convert valid user contacts correctly', () => { + const contactsApiData = { + entries: [ + { + id: 'user-1', + login: 'jane.smith@example.com', + name: 'Jane Smith', + type: 'user', + status: 'active', + }, + { + id: 'user-2', + login: 'john.doe@example.com', + name: 'John Doe', + type: 'user', + status: 'active', + }, + ], + }; + + const result = convertUserContactsResponse(contactsApiData, mockCurrentUserId); + + expect(result).toEqual([ + { + id: 'user-1', + email: 'jane.smith@example.com', + name: 'Jane Smith', + type: 'user', + value: 'jane.smith@example.com', + }, + { + id: 'user-2', + email: 'john.doe@example.com', + name: 'John Doe', + type: 'user', + value: 'john.doe@example.com', + }, + ]); + }); + }); + + describe('filtering logic', () => { + test('should filter out current user', () => { + const contactsApiData = { + entries: [ + { + id: mockCurrentUserId, + login: 'current.user@example.com', + name: 'Current User', + type: 'user', + status: 'active', + }, + { + id: 'user-1', + login: 'other.user@example.com', + name: 'Other User', + type: 'user', + status: 'active', + }, + ], + }; + + const result = convertUserContactsResponse(contactsApiData, mockCurrentUserId); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('user-1'); + }); + + test('should filter out app users (boxdevedition.com domain)', () => { + const contactsApiData = { + entries: [ + { + id: 'app-user-1', + login: 'app@boxdevedition.com', + name: 'App User', + type: 'user', + status: 'active', + }, + { + id: 'user-1', + login: 'regular.user@example.com', + name: 'Regular User', + type: 'user', + status: 'active', + }, + ], + }; + + const result = convertUserContactsResponse(contactsApiData, mockCurrentUserId); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('user-1'); + }); + + test.each([null, STATUS_INACTIVE])('should filter out users with invalid status', status => { + const contactsApiData = { + entries: [ + { + id: 'inactive-user', + login: 'inactive@example.com', + name: 'Inactive User', + type: 'user', + status, + }, + { + id: 'active-user', + login: 'active@example.com', + name: 'Active User', + type: 'user', + status: 'active', + }, + ], + }; + + const result = convertUserContactsResponse(contactsApiData, mockCurrentUserId); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('active-user'); + }); + + test('should filter out users without email', () => { + const contactsApiData = { + entries: [ + { + id: 'no-email-user', + login: null, + name: 'No Email User', + type: 'user', + status: 'active', + }, + { + id: 'user-with-email', + login: 'with.email@example.com', + name: 'User With Email', + type: 'user', + status: 'active', + }, + ], + }; + + const result = convertUserContactsResponse(contactsApiData, mockCurrentUserId); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('user-with-email'); + }); + }); + + test('should sort contacts by name alphabetically', () => { + const contactsApiData = { + entries: [ + { + id: 'user-3', + login: 'charlie@example.com', + name: 'Charlie Brown', + type: 'user', + status: 'active', + }, + { + id: 'user-1', + login: 'alice@example.com', + name: 'Alice Wonder', + type: 'user', + status: 'active', + }, + { + id: 'user-2', + login: 'bob@example.com', + name: 'Bob Builder', + type: 'user', + status: 'active', + }, + ], + }; + + const result = convertUserContactsResponse(contactsApiData, mockCurrentUserId); + + expect(result).toHaveLength(3); + expect(result[0].name).toBe('Alice Wonder'); + expect(result[1].name).toBe('Bob Builder'); + expect(result[2].name).toBe('Charlie Brown'); + }); + }); + + describe('convertGroupContactsResponse', () => { + describe('basic conversion', () => { + test('should return empty array when entries is empty', () => { + const contactsApiData = { entries: [] }; + const result = convertGroupContactsResponse(contactsApiData); + expect(result).toEqual([]); + }); + + test('should convert valid group contacts correctly', () => { + const contactsApiData = { + entries: [ + { + id: 'group-1', + name: 'Engineering Team', + type: 'group', + permissions: { + can_invite_as_collaborator: true, + }, + }, + { + id: 'group-2', + name: 'Marketing Team', + type: 'group', + permissions: { + can_invite_as_collaborator: true, + }, + }, + ], + }; + + const result = convertGroupContactsResponse(contactsApiData); + + expect(result).toEqual([ + { + id: 'group-1', + email: 'Group', + name: 'Engineering Team', + type: 'group', + value: 'Group', + }, + { + id: 'group-2', + email: 'Group', + name: 'Marketing Team', + type: 'group', + value: 'Group', + }, + ]); + }); + }); + + describe('filtering logic', () => { + test('should filter out groups without can_invite_as_collaborator permission', () => { + const contactsApiData = { + entries: [ + { + id: 'group-1', + name: 'Marketing Team', + type: 'group', + permissions: { + can_invite_as_collaborator: false, + }, + }, + { + id: 'group-2', + name: 'Marketing Team', + type: 'group', + }, + { + id: 'group-3', + name: 'Marketing Team', + type: 'group', + permissions: {}, + }, + { + id: 'group-4', + name: 'Engineering Team', + type: 'group', + permissions: { + can_invite_as_collaborator: true, + }, + }, + ], + }; + + const result = convertGroupContactsResponse(contactsApiData); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('group-4'); + }); + }); + + test('should sort groups by name alphabetically', () => { + const contactsApiData = { + entries: [ + { + id: 'group-3', + name: 'Charlie Group', + type: 'group', + permissions: { + can_invite_as_collaborator: true, + }, + }, + { + id: 'group-1', + name: 'Alice Group', + type: 'group', + permissions: { + can_invite_as_collaborator: true, + }, + }, + { + id: 'group-2', + name: 'Bob Group', + type: 'group', + permissions: { + can_invite_as_collaborator: true, + }, + }, + ], + }; + + const result = convertGroupContactsResponse(contactsApiData); + + expect(result).toHaveLength(3); + expect(result[0].name).toBe('Alice Group'); + expect(result[1].name).toBe('Bob Group'); + expect(result[2].name).toBe('Charlie Group'); + }); + }); +}); diff --git a/src/elements/content-sharing/utils/convertContactServiceData.ts b/src/elements/content-sharing/utils/convertContactServiceData.ts new file mode 100644 index 0000000000..45d92e09ae --- /dev/null +++ b/src/elements/content-sharing/utils/convertContactServiceData.ts @@ -0,0 +1,57 @@ +import { STATUS_INACTIVE } from '../../../constants'; + +const APP_USERS_DOMAIN_REGEXP = /boxdevedition.com/; +const sortByName = ({ name: nameA = '' }, { name: nameB = '' }) => nameA.localeCompare(nameB); + +/** + * Convert an enterprise users API response into an array of internal USM contacts. + */ +export const convertUserContactsResponse = (contactsApiData, currentUserId) => { + const { entries = [] } = contactsApiData; + + // Return all active users except for the current user and app users + return entries + .filter( + ({ id, login: email, status }) => + id !== currentUserId && + email && + !APP_USERS_DOMAIN_REGEXP.test(email) && + status && + status !== STATUS_INACTIVE, + ) + .map(contact => { + const { id, login: email, name, type } = contact; + return { + id, + email, + name, + type, + value: email, + }; + }) + .sort(sortByName); +}; + +/** + * Convert an enterprise groups API response into an array of internal USM contacts. + */ +export const convertGroupContactsResponse = contactsApiData => { + const { entries = [] } = contactsApiData; + + // Only return groups with the correct permissions + return entries + .filter(({ permissions }) => { + return permissions && permissions.can_invite_as_collaborator; + }) + .map(contact => { + const { id, name, type } = contact; + return { + id, + email: 'Group', // Need this for the avatar to work for isUserContactType + name, + type, + value: 'Group', + }; + }) + .sort(sortByName); +}; diff --git a/src/elements/content-sharing/utils/index.ts b/src/elements/content-sharing/utils/index.ts index 287b01c3e8..a7de99c363 100644 --- a/src/elements/content-sharing/utils/index.ts +++ b/src/elements/content-sharing/utils/index.ts @@ -1,5 +1,6 @@ +export { convertCollabsResponse } from './convertCollaborators'; export { convertItemResponse } from './convertItemResponse'; +export { convertGroupContactsResponse, convertUserContactsResponse } from './convertContactServiceData'; export { convertSharedLinkPermissions, convertSharedLinkSettings } from './convertSharingServiceData'; -export { convertCollabsResponse } from './convertCollaborators'; export { getAllowedAccessLevels } from './getAllowedAccessLevels'; export { getAllowedPermissionLevels } from './getAllowedPermissionLevels';