Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 57 additions & 51 deletions src/elements/content-sharing/ContentSharingV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@ import * as React from 'react';
import isEmpty from 'lodash/isEmpty';

import { UnifiedShareModal } from '@box/unified-share-modal';
import type { CollaborationRole, Item, SharedLink, User } from '@box/unified-share-modal';
import type { CollaborationRole, Collaborator, Item, SharedLink, User } from '@box/unified-share-modal';

import API from '../../api';
import { FIELD_ENTERPRISE, FIELD_HOSTNAME, TYPE_FILE, TYPE_FOLDER } from '../../constants';
import Internationalize from '../common/Internationalize';
import Providers from '../common/Providers';
import { CONTENT_SHARING_ITEM_FIELDS } from './constants';
import { convertItemResponse } from './utils';
import { fetchAvatars, fetchCollaborators, fetchCurrentUser, fetchItem } from './apis';
import { convertCollabsResponse, convertItemResponse } from './utils';

import type { ItemType, StringMap } from '../../common/types/core';
import type { Collaborations, ItemType, StringMap } from '../../common/types/core';
import type { AvatarURLMap } from './types';

export interface ContentSharingV2Props {
/** api - API instance */
Expand Down Expand Up @@ -39,10 +39,13 @@ function ContentSharingV2({
language,
messages,
}: ContentSharingV2Props) {
const [avatarURLMap, setAvatarURLMap] = React.useState<AvatarURLMap | null>(null);
const [item, setItem] = React.useState<Item | null>(null);
const [sharedLink, setSharedLink] = React.useState<SharedLink | null>(null);
const [currentUser, setCurrentUser] = React.useState<User | null>(null);
const [collaborationRoles, setCollaborationRoles] = React.useState<CollaborationRole[] | null>(null);
const [collaborators, setCollaborators] = React.useState<Collaborator[] | null>(null);
const [collaboratorsData, setCollaboratorsData] = React.useState<Collaborations | null>(null);

// Handle successful GET requests to /files or /folders
const handleGetItemSuccess = React.useCallback(itemData => {
Expand All @@ -62,70 +65,72 @@ function ContentSharingV2({
setSharedLink(null);
setCurrentUser(null);
setCollaborationRoles(null);
setAvatarURLMap(null);
setCollaborators(null);
setCollaboratorsData(null);
}, [api]);

// Get initial data for the item
React.useEffect(() => {
const getItem = () => {
if (itemType === TYPE_FILE) {
api.getFileAPI().getFile(
itemID,
handleGetItemSuccess,
{},
{
fields: CONTENT_SHARING_ITEM_FIELDS,
},
);
} else if (itemType === TYPE_FOLDER) {
api.getFolderAPI().getFolderFields(
itemID,
handleGetItemSuccess,
{},
{
fields: CONTENT_SHARING_ITEM_FIELDS,
},
);
}
};
if (!api || isEmpty(api) || item) return;

if (api && !isEmpty(api) && !item && !sharedLink) {
getItem();
}
(async () => {
const itemData = await fetchItem({ api, itemID, itemType });
handleGetItemSuccess(itemData);
})();
}, [api, item, itemID, itemType, sharedLink, handleGetItemSuccess]);

// Get initial data for the user
// Get current user
React.useEffect(() => {
if (!api || isEmpty(api) || !item || currentUser) return;

const getUserSuccess = userData => {
const { enterprise, id } = userData;
setCurrentUser({
id,
enterprise: {
name: enterprise ? enterprise.name : '',
},
enterprise: { name: enterprise ? enterprise.name : '' },
});
};

const getUserData = () => {
api.getUsersAPI(false).getUser(
itemID,
getUserSuccess,
{},
{
params: {
fields: [FIELD_ENTERPRISE, FIELD_HOSTNAME].toString(),
},
},
);
};
(async () => {
const userData = await fetchCurrentUser({ api, itemID });
getUserSuccess(userData);
})();
}, [api, currentUser, item, itemID, itemType, sharedLink]);

// Get collaborators
React.useEffect(() => {
if (!api || isEmpty(api) || !item || collaboratorsData) return;

if (api && !isEmpty(api) && item && sharedLink && !currentUser) {
getUserData();
(async () => {
try {
const response = await fetchCollaborators({ api, itemID, itemType });
setCollaboratorsData(response);
} catch {
setCollaboratorsData({ entries: [], next_marker: null });
}
})();
}, [api, collaboratorsData, item, itemID, itemType]);

// Get avatars when collaborators are available
React.useEffect(() => {
if (avatarURLMap || !collaboratorsData || !collaboratorsData.entries) return;

(async () => {
const response = await fetchAvatars({ api, itemID, collaborators: collaboratorsData.entries });
setAvatarURLMap(response);
})();
}, [api, avatarURLMap, collaboratorsData, itemID]);

// Return processed data when both are ready
React.useEffect(() => {
if (collaboratorsData && avatarURLMap) {
const collaboratorsWithAvatars = convertCollabsResponse(collaboratorsData, avatarURLMap);
setCollaborators(collaboratorsWithAvatars);
}
}, [api, currentUser, item, itemID, itemType, sharedLink]);
}, [collaboratorsData, avatarURLMap]);

const config = {
sharedLinkEmail: false,
};
const config = { sharedLinkEmail: false };

return (
<Internationalize language={language} messages={messages}>
Expand All @@ -134,6 +139,7 @@ function ContentSharingV2({
<UnifiedShareModal
config={config}
collaborationRoles={collaborationRoles}
collaborators={collaborators}
currentUser={currentUser}
item={item}
sharedLink={sharedLink}
Expand Down
75 changes: 47 additions & 28 deletions src/elements/content-sharing/__tests__/ContentSharingV2.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,18 @@ import {
MOCK_ITEM,
MOCK_ITEM_API_RESPONSE_WITH_SHARED_LINK,
MOCK_ITEM_API_RESPONSE_WITH_CLASSIFICATION,
MOCK_COLLABORATIONS_RESPONSE,
mockAvatarURLMap,
} from '../utils/__mocks__/ContentSharingV2Mocks';
import { CONTENT_SHARING_ITEM_FIELDS } from '../constants';

import ContentSharingV2 from '../ContentSharingV2';

const createAPIMock = (fileAPI, folderAPI, usersAPI) => ({
const createAPIMock = (fileAPI, folderAPI, usersAPI, collaborationsAPI) => ({
getFileAPI: jest.fn().mockReturnValue(fileAPI),
getFolderAPI: jest.fn().mockReturnValue(folderAPI),
getUsersAPI: jest.fn().mockReturnValue(usersAPI),
getFileCollaborationsAPI: jest.fn().mockReturnValue(collaborationsAPI),
});

const createSuccessMock = responseFromAPI => (id, successFn) => {
Expand All @@ -33,10 +36,14 @@ const getFileMockWithClassification = jest
.fn()
.mockImplementation(createSuccessMock(MOCK_ITEM_API_RESPONSE_WITH_CLASSIFICATION));
const getDefaultFolderMock = jest.fn().mockImplementation(createSuccessMock(DEFAULT_ITEM_API_RESPONSE));
const getCollaborationsMock = jest.fn().mockImplementation(createSuccessMock(MOCK_COLLABORATIONS_RESPONSE));
const getAvatarUrlMock = jest.fn().mockImplementation(userID => mockAvatarURLMap[userID] ?? null);

const defaultAPIMock = createAPIMock(
{ getFile: getDefaultFileMock },
{ getFolderFields: getDefaultFolderMock },
{ getUser: getDefaultUserMock },
{ getUser: getDefaultUserMock, getAvatarUrlWithAccessToken: getAvatarUrlMock },
{ getCollaborations: getCollaborationsMock },
);

const getWrapper = (props): RenderResult =>
Expand All @@ -58,19 +65,13 @@ describe('elements/content-sharing/ContentSharingV2', () => {
test('should see the correct elements for files', async () => {
getWrapper({});
await waitFor(() => {
expect(getDefaultFileMock).toHaveBeenCalledWith(
MOCK_ITEM.id,
expect.any(Function),
{},
{
fields: CONTENT_SHARING_ITEM_FIELDS,
},
);
expect(getDefaultFileMock).toHaveBeenCalledWith(MOCK_ITEM.id, expect.any(Function), expect.any(Function), {
fields: CONTENT_SHARING_ITEM_FIELDS,
});
expect(screen.getByRole('heading', { name: /Box Development Guide.pdf/i })).toBeVisible();
expect(screen.getByRole('combobox', { name: 'Invite People' })).toBeVisible();
expect(screen.getByRole('switch', { name: 'Shared link' })).toBeVisible();
});

expect(screen.getByRole('heading', { name: 'Share ‘Box Development Guide.pdf’' })).toBeVisible();
expect(screen.getByRole('combobox', { name: 'Invite People' })).toBeVisible();
expect(screen.getByRole('switch', { name: 'Shared link' })).toBeVisible();
});

test('should see the correct elements for folders', async () => {
Expand All @@ -79,27 +80,28 @@ describe('elements/content-sharing/ContentSharingV2', () => {
expect(getDefaultFolderMock).toHaveBeenCalledWith(
MOCK_ITEM.id,
expect.any(Function),
{},
expect.any(Function),
{
fields: CONTENT_SHARING_ITEM_FIELDS,
},
);
expect(screen.getByRole('heading', { name: 'Share ‘Box Development Guide.pdf’' })).toBeVisible();
expect(screen.getByRole('combobox', { name: 'Invite People' })).toBeVisible();
expect(screen.getByRole('switch', { name: 'Shared link' })).toBeVisible();
});

expect(screen.getByRole('heading', { name: 'Share ‘Box Development Guide.pdf’' })).toBeVisible();
expect(screen.getByRole('combobox', { name: 'Invite People' })).toBeVisible();
expect(screen.getByRole('switch', { name: 'Shared link' })).toBeVisible();
});

test('should see the shared link elements if shared link is present', async () => {
getWrapper({
api: createAPIMock({ getFile: getFileMockWithSharedLink }, null, { getUser: getDefaultUserMock }),
});
const apiWithSharedLink = {
...defaultAPIMock,
getFileAPI: jest.fn().mockReturnValue({ getFile: getFileMockWithSharedLink }),
};
getWrapper({ api: apiWithSharedLink });
await waitFor(() => {
expect(getFileMockWithSharedLink).toHaveBeenCalledWith(
MOCK_ITEM.id,
expect.any(Function),
{},
expect.any(Function),
{
fields: CONTENT_SHARING_ITEM_FIELDS,
},
Expand All @@ -114,19 +116,36 @@ describe('elements/content-sharing/ContentSharingV2', () => {
});

test('should see the classification elements if classification is present', async () => {
getWrapper({
api: createAPIMock({ getFile: getFileMockWithClassification }, null, { getUser: getDefaultUserMock }),
});
const apiWithClassification = {
...defaultAPIMock,
getFileAPI: jest.fn().mockReturnValue({ getFile: getFileMockWithClassification }),
};
getWrapper({ api: apiWithClassification });
await waitFor(() => {
expect(getFileMockWithClassification).toHaveBeenCalledWith(
MOCK_ITEM.id,
expect.any(Function),
{},
expect.any(Function),
{
fields: CONTENT_SHARING_ITEM_FIELDS,
},
);
expect(screen.getByText('BLUE')).toBeVisible();
});
});

test('should process collaborators with avatars correctly', async () => {
getWrapper({});

await waitFor(() => {
expect(getCollaborationsMock).toHaveBeenCalledWith(
MOCK_ITEM.id,
expect.any(Function),
expect.any(Function),
);
expect(getAvatarUrlMock).toHaveBeenCalledWith('456', MOCK_ITEM.id);
expect(getAvatarUrlMock).toHaveBeenCalledWith('457', MOCK_ITEM.id);
expect(getAvatarUrlMock).toHaveBeenCalledWith('458', MOCK_ITEM.id);
});
expect(screen.getByText('BLUE')).toBeVisible();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { DEFAULT_USER_API_RESPONSE, MOCK_ITEM } from '../../utils/__mocks__/ContentSharingV2Mocks';
import { fetchAvatars } from '..';
import { createSuccessMock, createUsersAPIMock } from './testUtils';

const getAvatarUrlMock = jest.fn();
const getDefaultUserMock = jest.fn().mockImplementation(createSuccessMock(DEFAULT_USER_API_RESPONSE));
const defaultAPIMock = createUsersAPIMock({
getUser: getDefaultUserMock,
getAvatarUrlWithAccessToken: getAvatarUrlMock,
});

const mockCollaborations = [
{ accessible_by: { id: 123 } },
{ accessible_by: { id: 456 } },
{ accessible_by: { id: 789 } },
];

describe('content-sharing/apis/fetchAvatars', () => {
beforeEach(() => {
jest.clearAllMocks();
});

test('should fetch avatars successfully', async () => {
getAvatarUrlMock
.mockResolvedValueOnce('https://example.com/avatar1.jpg')
.mockResolvedValueOnce('https://example.com/avatar2.jpg')
.mockResolvedValueOnce('https://example.com/avatar3.jpg');

const result = await fetchAvatars({
api: defaultAPIMock,
itemID: MOCK_ITEM.id,
collaborators: mockCollaborations,
});

expect(defaultAPIMock.getUsersAPI).toHaveBeenCalledWith(false);
expect(getAvatarUrlMock).toHaveBeenCalledTimes(3);
expect(getAvatarUrlMock).toHaveBeenCalledWith('123', MOCK_ITEM.id);
expect(getAvatarUrlMock).toHaveBeenCalledWith('456', MOCK_ITEM.id);
expect(getAvatarUrlMock).toHaveBeenCalledWith('789', MOCK_ITEM.id);
expect(result).toEqual({
123: 'https://example.com/avatar1.jpg',
456: 'https://example.com/avatar2.jpg',
789: 'https://example.com/avatar3.jpg',
});
});

test('should handle avatar fetch errors gracefully', async () => {
getAvatarUrlMock
.mockResolvedValueOnce('https://example.com/avatar1.jpg')
.mockRejectedValueOnce(new Error('Avatar fetch failed'))
.mockResolvedValueOnce('https://example.com/avatar3.jpg');

const result = await fetchAvatars({
api: defaultAPIMock,
itemID: MOCK_ITEM.id,
collaborators: mockCollaborations,
});

expect(result).toEqual({
123: 'https://example.com/avatar1.jpg',
456: null,
789: 'https://example.com/avatar3.jpg',
});
});

test('should handle collaborators without accessible_by', async () => {
const collaboratorsWithMissingData = [{ accessible_by: { id: 123 } }, {}, { accessible_by: null }];

getAvatarUrlMock.mockResolvedValue('https://example.com/avatar.jpg');

const result = await fetchAvatars({
api: defaultAPIMock,
itemID: MOCK_ITEM.id,
collaborators: collaboratorsWithMissingData,
});

expect(getAvatarUrlMock).toHaveBeenCalledTimes(1);
expect(getAvatarUrlMock).toHaveBeenCalledWith('123', MOCK_ITEM.id);
expect(result).toEqual({
123: 'https://example.com/avatar.jpg',
});
});

test('should handle empty collaborators array', async () => {
const result = await fetchAvatars({
api: defaultAPIMock,
itemID: MOCK_ITEM.id,
collaborators: [],
});

expect(getAvatarUrlMock).not.toHaveBeenCalled();
expect(result).toEqual({});
});
});
Loading