Skip to content

Commit 6937b60

Browse files
feat(content-sharing): convert api response for collabs (#4322)
* feat(content-sharing): Convert api response for collaborators * feat(content-sharing): Convert api response for collaborators * fix: nits * fix: add test coverage * fix: test * fix: remove console * fix: update contentSharingV2 useEffect logic * fix: add await for tests --------- Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
1 parent 0061f34 commit 6937b60

23 files changed

+1005
-124
lines changed

src/elements/content-sharing/ContentSharingV2.tsx

Lines changed: 57 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,16 @@ import * as React from 'react';
22
import isEmpty from 'lodash/isEmpty';
33

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

77
import API from '../../api';
8-
import { FIELD_ENTERPRISE, FIELD_HOSTNAME, TYPE_FILE, TYPE_FOLDER } from '../../constants';
98
import Internationalize from '../common/Internationalize';
109
import Providers from '../common/Providers';
11-
import { CONTENT_SHARING_ITEM_FIELDS } from './constants';
12-
import { convertItemResponse } from './utils';
10+
import { fetchAvatars, fetchCollaborators, fetchCurrentUser, fetchItem } from './apis';
11+
import { convertCollabsResponse, convertItemResponse } from './utils';
1312

14-
import type { ItemType, StringMap } from '../../common/types/core';
13+
import type { Collaborations, ItemType, StringMap } from '../../common/types/core';
14+
import type { AvatarURLMap } from './types';
1515

1616
export interface ContentSharingV2Props {
1717
/** api - API instance */
@@ -39,10 +39,13 @@ function ContentSharingV2({
3939
language,
4040
messages,
4141
}: ContentSharingV2Props) {
42+
const [avatarURLMap, setAvatarURLMap] = React.useState<AvatarURLMap | null>(null);
4243
const [item, setItem] = React.useState<Item | null>(null);
4344
const [sharedLink, setSharedLink] = React.useState<SharedLink | null>(null);
4445
const [currentUser, setCurrentUser] = React.useState<User | null>(null);
4546
const [collaborationRoles, setCollaborationRoles] = React.useState<CollaborationRole[] | null>(null);
47+
const [collaborators, setCollaborators] = React.useState<Collaborator[] | null>(null);
48+
const [collaboratorsData, setCollaboratorsData] = React.useState<Collaborations | null>(null);
4649

4750
// Handle successful GET requests to /files or /folders
4851
const handleGetItemSuccess = React.useCallback(itemData => {
@@ -62,70 +65,72 @@ function ContentSharingV2({
6265
setSharedLink(null);
6366
setCurrentUser(null);
6467
setCollaborationRoles(null);
68+
setAvatarURLMap(null);
69+
setCollaborators(null);
70+
setCollaboratorsData(null);
6571
}, [api]);
6672

6773
// Get initial data for the item
6874
React.useEffect(() => {
69-
const getItem = () => {
70-
if (itemType === TYPE_FILE) {
71-
api.getFileAPI().getFile(
72-
itemID,
73-
handleGetItemSuccess,
74-
{},
75-
{
76-
fields: CONTENT_SHARING_ITEM_FIELDS,
77-
},
78-
);
79-
} else if (itemType === TYPE_FOLDER) {
80-
api.getFolderAPI().getFolderFields(
81-
itemID,
82-
handleGetItemSuccess,
83-
{},
84-
{
85-
fields: CONTENT_SHARING_ITEM_FIELDS,
86-
},
87-
);
88-
}
89-
};
75+
if (!api || isEmpty(api) || item) return;
9076

91-
if (api && !isEmpty(api) && !item && !sharedLink) {
92-
getItem();
93-
}
77+
(async () => {
78+
const itemData = await fetchItem({ api, itemID, itemType });
79+
handleGetItemSuccess(itemData);
80+
})();
9481
}, [api, item, itemID, itemType, sharedLink, handleGetItemSuccess]);
9582

96-
// Get initial data for the user
83+
// Get current user
9784
React.useEffect(() => {
85+
if (!api || isEmpty(api) || !item || currentUser) return;
86+
9887
const getUserSuccess = userData => {
9988
const { enterprise, id } = userData;
10089
setCurrentUser({
10190
id,
102-
enterprise: {
103-
name: enterprise ? enterprise.name : '',
104-
},
91+
enterprise: { name: enterprise ? enterprise.name : '' },
10592
});
10693
};
10794

108-
const getUserData = () => {
109-
api.getUsersAPI(false).getUser(
110-
itemID,
111-
getUserSuccess,
112-
{},
113-
{
114-
params: {
115-
fields: [FIELD_ENTERPRISE, FIELD_HOSTNAME].toString(),
116-
},
117-
},
118-
);
119-
};
95+
(async () => {
96+
const userData = await fetchCurrentUser({ api, itemID });
97+
getUserSuccess(userData);
98+
})();
99+
}, [api, currentUser, item, itemID, itemType, sharedLink]);
100+
101+
// Get collaborators
102+
React.useEffect(() => {
103+
if (!api || isEmpty(api) || !item || collaboratorsData) return;
120104

121-
if (api && !isEmpty(api) && item && sharedLink && !currentUser) {
122-
getUserData();
105+
(async () => {
106+
try {
107+
const response = await fetchCollaborators({ api, itemID, itemType });
108+
setCollaboratorsData(response);
109+
} catch {
110+
setCollaboratorsData({ entries: [], next_marker: null });
111+
}
112+
})();
113+
}, [api, collaboratorsData, item, itemID, itemType]);
114+
115+
// Get avatars when collaborators are available
116+
React.useEffect(() => {
117+
if (avatarURLMap || !collaboratorsData || !collaboratorsData.entries) return;
118+
119+
(async () => {
120+
const response = await fetchAvatars({ api, itemID, collaborators: collaboratorsData.entries });
121+
setAvatarURLMap(response);
122+
})();
123+
}, [api, avatarURLMap, collaboratorsData, itemID]);
124+
125+
// Return processed data when both are ready
126+
React.useEffect(() => {
127+
if (collaboratorsData && avatarURLMap) {
128+
const collaboratorsWithAvatars = convertCollabsResponse(collaboratorsData, avatarURLMap);
129+
setCollaborators(collaboratorsWithAvatars);
123130
}
124-
}, [api, currentUser, item, itemID, itemType, sharedLink]);
131+
}, [collaboratorsData, avatarURLMap]);
125132

126-
const config = {
127-
sharedLinkEmail: false,
128-
};
133+
const config = { sharedLinkEmail: false };
129134

130135
return (
131136
<Internationalize language={language} messages={messages}>
@@ -134,6 +139,7 @@ function ContentSharingV2({
134139
<UnifiedShareModal
135140
config={config}
136141
collaborationRoles={collaborationRoles}
142+
collaborators={collaborators}
137143
currentUser={currentUser}
138144
item={item}
139145
sharedLink={sharedLink}

src/elements/content-sharing/__tests__/ContentSharingV2.test.tsx

Lines changed: 47 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,18 @@ import {
77
MOCK_ITEM,
88
MOCK_ITEM_API_RESPONSE_WITH_SHARED_LINK,
99
MOCK_ITEM_API_RESPONSE_WITH_CLASSIFICATION,
10+
MOCK_COLLABORATIONS_RESPONSE,
11+
mockAvatarURLMap,
1012
} from '../utils/__mocks__/ContentSharingV2Mocks';
1113
import { CONTENT_SHARING_ITEM_FIELDS } from '../constants';
1214

1315
import ContentSharingV2 from '../ContentSharingV2';
1416

15-
const createAPIMock = (fileAPI, folderAPI, usersAPI) => ({
17+
const createAPIMock = (fileAPI, folderAPI, usersAPI, collaborationsAPI) => ({
1618
getFileAPI: jest.fn().mockReturnValue(fileAPI),
1719
getFolderAPI: jest.fn().mockReturnValue(folderAPI),
1820
getUsersAPI: jest.fn().mockReturnValue(usersAPI),
21+
getFileCollaborationsAPI: jest.fn().mockReturnValue(collaborationsAPI),
1922
});
2023

2124
const createSuccessMock = responseFromAPI => (id, successFn) => {
@@ -33,10 +36,14 @@ const getFileMockWithClassification = jest
3336
.fn()
3437
.mockImplementation(createSuccessMock(MOCK_ITEM_API_RESPONSE_WITH_CLASSIFICATION));
3538
const getDefaultFolderMock = jest.fn().mockImplementation(createSuccessMock(DEFAULT_ITEM_API_RESPONSE));
39+
const getCollaborationsMock = jest.fn().mockImplementation(createSuccessMock(MOCK_COLLABORATIONS_RESPONSE));
40+
const getAvatarUrlMock = jest.fn().mockImplementation(userID => mockAvatarURLMap[userID] ?? null);
41+
3642
const defaultAPIMock = createAPIMock(
3743
{ getFile: getDefaultFileMock },
3844
{ getFolderFields: getDefaultFolderMock },
39-
{ getUser: getDefaultUserMock },
45+
{ getUser: getDefaultUserMock, getAvatarUrlWithAccessToken: getAvatarUrlMock },
46+
{ getCollaborations: getCollaborationsMock },
4047
);
4148

4249
const getWrapper = (props): RenderResult =>
@@ -58,19 +65,13 @@ describe('elements/content-sharing/ContentSharingV2', () => {
5865
test('should see the correct elements for files', async () => {
5966
getWrapper({});
6067
await waitFor(() => {
61-
expect(getDefaultFileMock).toHaveBeenCalledWith(
62-
MOCK_ITEM.id,
63-
expect.any(Function),
64-
{},
65-
{
66-
fields: CONTENT_SHARING_ITEM_FIELDS,
67-
},
68-
);
68+
expect(getDefaultFileMock).toHaveBeenCalledWith(MOCK_ITEM.id, expect.any(Function), expect.any(Function), {
69+
fields: CONTENT_SHARING_ITEM_FIELDS,
70+
});
71+
expect(screen.getByRole('heading', { name: /Box Development Guide.pdf/i })).toBeVisible();
72+
expect(screen.getByRole('combobox', { name: 'Invite People' })).toBeVisible();
73+
expect(screen.getByRole('switch', { name: 'Shared link' })).toBeVisible();
6974
});
70-
71-
expect(screen.getByRole('heading', { name: 'Share ‘Box Development Guide.pdf’' })).toBeVisible();
72-
expect(screen.getByRole('combobox', { name: 'Invite People' })).toBeVisible();
73-
expect(screen.getByRole('switch', { name: 'Shared link' })).toBeVisible();
7475
});
7576

7677
test('should see the correct elements for folders', async () => {
@@ -79,27 +80,28 @@ describe('elements/content-sharing/ContentSharingV2', () => {
7980
expect(getDefaultFolderMock).toHaveBeenCalledWith(
8081
MOCK_ITEM.id,
8182
expect.any(Function),
82-
{},
83+
expect.any(Function),
8384
{
8485
fields: CONTENT_SHARING_ITEM_FIELDS,
8586
},
8687
);
88+
expect(screen.getByRole('heading', { name: 'Share ‘Box Development Guide.pdf’' })).toBeVisible();
89+
expect(screen.getByRole('combobox', { name: 'Invite People' })).toBeVisible();
90+
expect(screen.getByRole('switch', { name: 'Shared link' })).toBeVisible();
8791
});
88-
89-
expect(screen.getByRole('heading', { name: 'Share ‘Box Development Guide.pdf’' })).toBeVisible();
90-
expect(screen.getByRole('combobox', { name: 'Invite People' })).toBeVisible();
91-
expect(screen.getByRole('switch', { name: 'Shared link' })).toBeVisible();
9292
});
9393

9494
test('should see the shared link elements if shared link is present', async () => {
95-
getWrapper({
96-
api: createAPIMock({ getFile: getFileMockWithSharedLink }, null, { getUser: getDefaultUserMock }),
97-
});
95+
const apiWithSharedLink = {
96+
...defaultAPIMock,
97+
getFileAPI: jest.fn().mockReturnValue({ getFile: getFileMockWithSharedLink }),
98+
};
99+
getWrapper({ api: apiWithSharedLink });
98100
await waitFor(() => {
99101
expect(getFileMockWithSharedLink).toHaveBeenCalledWith(
100102
MOCK_ITEM.id,
101103
expect.any(Function),
102-
{},
104+
expect.any(Function),
103105
{
104106
fields: CONTENT_SHARING_ITEM_FIELDS,
105107
},
@@ -114,19 +116,36 @@ describe('elements/content-sharing/ContentSharingV2', () => {
114116
});
115117

116118
test('should see the classification elements if classification is present', async () => {
117-
getWrapper({
118-
api: createAPIMock({ getFile: getFileMockWithClassification }, null, { getUser: getDefaultUserMock }),
119-
});
119+
const apiWithClassification = {
120+
...defaultAPIMock,
121+
getFileAPI: jest.fn().mockReturnValue({ getFile: getFileMockWithClassification }),
122+
};
123+
getWrapper({ api: apiWithClassification });
120124
await waitFor(() => {
121125
expect(getFileMockWithClassification).toHaveBeenCalledWith(
122126
MOCK_ITEM.id,
123127
expect.any(Function),
124-
{},
128+
expect.any(Function),
125129
{
126130
fields: CONTENT_SHARING_ITEM_FIELDS,
127131
},
128132
);
133+
expect(screen.getByText('BLUE')).toBeVisible();
134+
});
135+
});
136+
137+
test('should process collaborators with avatars correctly', async () => {
138+
getWrapper({});
139+
140+
await waitFor(() => {
141+
expect(getCollaborationsMock).toHaveBeenCalledWith(
142+
MOCK_ITEM.id,
143+
expect.any(Function),
144+
expect.any(Function),
145+
);
146+
expect(getAvatarUrlMock).toHaveBeenCalledWith('456', MOCK_ITEM.id);
147+
expect(getAvatarUrlMock).toHaveBeenCalledWith('457', MOCK_ITEM.id);
148+
expect(getAvatarUrlMock).toHaveBeenCalledWith('458', MOCK_ITEM.id);
129149
});
130-
expect(screen.getByText('BLUE')).toBeVisible();
131150
});
132151
});
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { DEFAULT_USER_API_RESPONSE, MOCK_ITEM } from '../../utils/__mocks__/ContentSharingV2Mocks';
2+
import { fetchAvatars } from '..';
3+
import { createSuccessMock, createUsersAPIMock } from './testUtils';
4+
5+
const getAvatarUrlMock = jest.fn();
6+
const getDefaultUserMock = jest.fn().mockImplementation(createSuccessMock(DEFAULT_USER_API_RESPONSE));
7+
const defaultAPIMock = createUsersAPIMock({
8+
getUser: getDefaultUserMock,
9+
getAvatarUrlWithAccessToken: getAvatarUrlMock,
10+
});
11+
12+
const mockCollaborations = [
13+
{ accessible_by: { id: 123 } },
14+
{ accessible_by: { id: 456 } },
15+
{ accessible_by: { id: 789 } },
16+
];
17+
18+
describe('content-sharing/apis/fetchAvatars', () => {
19+
beforeEach(() => {
20+
jest.clearAllMocks();
21+
});
22+
23+
test('should fetch avatars successfully', async () => {
24+
getAvatarUrlMock
25+
.mockResolvedValueOnce('https://example.com/avatar1.jpg')
26+
.mockResolvedValueOnce('https://example.com/avatar2.jpg')
27+
.mockResolvedValueOnce('https://example.com/avatar3.jpg');
28+
29+
const result = await fetchAvatars({
30+
api: defaultAPIMock,
31+
itemID: MOCK_ITEM.id,
32+
collaborators: mockCollaborations,
33+
});
34+
35+
expect(defaultAPIMock.getUsersAPI).toHaveBeenCalledWith(false);
36+
expect(getAvatarUrlMock).toHaveBeenCalledTimes(3);
37+
expect(getAvatarUrlMock).toHaveBeenCalledWith('123', MOCK_ITEM.id);
38+
expect(getAvatarUrlMock).toHaveBeenCalledWith('456', MOCK_ITEM.id);
39+
expect(getAvatarUrlMock).toHaveBeenCalledWith('789', MOCK_ITEM.id);
40+
expect(result).toEqual({
41+
123: 'https://example.com/avatar1.jpg',
42+
456: 'https://example.com/avatar2.jpg',
43+
789: 'https://example.com/avatar3.jpg',
44+
});
45+
});
46+
47+
test('should handle avatar fetch errors gracefully', async () => {
48+
getAvatarUrlMock
49+
.mockResolvedValueOnce('https://example.com/avatar1.jpg')
50+
.mockRejectedValueOnce(new Error('Avatar fetch failed'))
51+
.mockResolvedValueOnce('https://example.com/avatar3.jpg');
52+
53+
const result = await fetchAvatars({
54+
api: defaultAPIMock,
55+
itemID: MOCK_ITEM.id,
56+
collaborators: mockCollaborations,
57+
});
58+
59+
expect(result).toEqual({
60+
123: 'https://example.com/avatar1.jpg',
61+
456: null,
62+
789: 'https://example.com/avatar3.jpg',
63+
});
64+
});
65+
66+
test('should handle collaborators without accessible_by', async () => {
67+
const collaboratorsWithMissingData = [{ accessible_by: { id: 123 } }, {}, { accessible_by: null }];
68+
69+
getAvatarUrlMock.mockResolvedValue('https://example.com/avatar.jpg');
70+
71+
const result = await fetchAvatars({
72+
api: defaultAPIMock,
73+
itemID: MOCK_ITEM.id,
74+
collaborators: collaboratorsWithMissingData,
75+
});
76+
77+
expect(getAvatarUrlMock).toHaveBeenCalledTimes(1);
78+
expect(getAvatarUrlMock).toHaveBeenCalledWith('123', MOCK_ITEM.id);
79+
expect(result).toEqual({
80+
123: 'https://example.com/avatar.jpg',
81+
});
82+
});
83+
84+
test('should handle empty collaborators array', async () => {
85+
const result = await fetchAvatars({
86+
api: defaultAPIMock,
87+
itemID: MOCK_ITEM.id,
88+
collaborators: [],
89+
});
90+
91+
expect(getAvatarUrlMock).not.toHaveBeenCalled();
92+
expect(result).toEqual({});
93+
});
94+
});

0 commit comments

Comments
 (0)