Skip to content

Commit f0c9efd

Browse files
feat(content-sharing): Handle error when fetch init data failed (#4350)
* feat(content-sharing): Handle error when fetch init data failed * fix: nit * fix: tests * fix: handle default error message case * fix: nit --------- Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
1 parent d0ce8a6 commit f0c9efd

File tree

9 files changed

+214
-87
lines changed

9 files changed

+214
-87
lines changed

i18n/en-US.properties

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,10 @@ be.contentInsights.trendYear = PAST YEAR
138138
be.contentSharing.badRequestError = The request for this item was malformed.
139139
# Message that appears when collaborators cannot be retrieved in the ContentSharing Element.
140140
be.contentSharing.collaboratorsLoadingError = Could not retrieve collaborators for this item.
141+
# Default error notification text rendered when API fails
142+
be.contentSharing.defaultErrorNoticeText = Something went wrong. Please try again later.
143+
# Icon label for the error notifications
144+
be.contentSharing.errorNoticeIcon = Error
141145
# Message that appears when users cannot be retrieved in the ContentSharing Element.
142146
be.contentSharing.getContactsError = Could not retrieve contacts.
143147
# Display text for a Group contact type
@@ -148,6 +152,8 @@ be.contentSharing.loadingError = Could not load shared link for this item.
148152
be.contentSharing.noAccessError = You do not have access to this item.
149153
# Message that appears when the item for the ContentSharing Element cannot be found.
150154
be.contentSharing.notFoundError = Could not find shared link for this item.
155+
# Close button aria label for the notifications
156+
be.contentSharing.noticeCloseLabel = Close
151157
# Message that appears when collaborators cannot be added to the shared link in the ContentSharing Element.
152158
be.contentSharing.sendInvitationsError = {count, plural, one {Failed to invite a collaborator.} other {Failed to invite {count} collaborators.}}
153159
# Message that appears when collaborators were added to the shared link in the ContentSharing Element.

src/elements/content-sharing/ContentSharing.js

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import API from '../../api';
1212
// $FlowFixMe
1313
import { withBlueprintModernization } from '../common/withBlueprintModernization';
1414
import { isFeatureEnabled } from '../common/feature-checking';
15+
import Internationalize from '../common/Internationalize';
16+
import Providers from '../common/Providers';
1517
import SharingModal from './SharingModal';
1618
// $FlowFixMe
1719
import ContentSharingV2 from './ContentSharingV2';
@@ -117,16 +119,13 @@ function ContentSharing({
117119
if (isFeatureEnabled(features, 'contentSharingV2')) {
118120
return (
119121
api && (
120-
<ContentSharingV2
121-
api={api}
122-
itemId={itemID}
123-
itemType={itemType}
124-
hasProviders={hasProviders}
125-
language={language}
126-
messages={messages}
127-
>
128-
{children}
129-
</ContentSharingV2>
122+
<Internationalize language={language} messages={messages}>
123+
<Providers hasProviders={hasProviders}>
124+
<ContentSharingV2 api={api} itemId={itemID} itemType={itemType}>
125+
{children}
126+
</ContentSharingV2>
127+
</Providers>
128+
</Internationalize>
130129
)
131130
);
132131
}

src/elements/content-sharing/ContentSharingV2.tsx

Lines changed: 73 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,23 @@
11
import * as React from 'react';
2+
import { useIntl } from 'react-intl';
23
import isEmpty from 'lodash/isEmpty';
3-
4+
import { useNotification } from '@box/blueprint-web';
45
import { UnifiedShareModal } from '@box/unified-share-modal';
56
import type { CollaborationRole, Collaborator, Item, SharedLink, User } from '@box/unified-share-modal';
67

78
import API from '../../api';
8-
import Internationalize from '../common/Internationalize';
9-
import Providers from '../common/Providers';
109
import { withBlueprintModernization } from '../common/withBlueprintModernization';
1110
import { fetchAvatars, fetchCollaborators, fetchCurrentUser, fetchItem } from './apis';
11+
import { CONTENT_SHARING_ERRORS } from './constants';
1212
import { useContactService, useSharingService } from './hooks';
1313
import { convertCollabsResponse, convertItemResponse } from './utils';
1414

15-
import type { Collaborations, ItemType, StringMap } from '../../common/types/core';
15+
import type { Collaborations, ItemType } from '../../common/types/core';
16+
import type { ElementsXhrError } from '../../common/types/api';
1617
import type { AvatarURLMap } from './types';
1718

19+
import messages from './messages';
20+
1821
export interface ContentSharingV2Props {
1922
/** api - API instance */
2023
api: API;
@@ -24,25 +27,12 @@ export interface ContentSharingV2Props {
2427
itemId: string;
2528
/** itemType - "file" or "folder" */
2629
itemType: ItemType;
27-
/** hasProviders - Whether the element has providers for USM already */
28-
hasProviders?: boolean;
29-
/** language - Language used for the element */
30-
language?: string;
31-
/** messages - Localized strings used by the element */
32-
messages?: StringMap;
3330
}
3431

35-
function ContentSharingV2({
36-
api,
37-
children,
38-
itemId,
39-
itemType,
40-
hasProviders,
41-
language,
42-
messages,
43-
}: ContentSharingV2Props) {
32+
function ContentSharingV2({ api, children, itemId, itemType }: ContentSharingV2Props) {
4433
const [avatarUrlMap, setAvatarUrlMap] = React.useState<AvatarURLMap | null>(null);
4534
const [item, setItem] = React.useState<Item | null>(null);
35+
const [hasError, setHasError] = React.useState<boolean>(false);
4636
const [sharedLink, setSharedLink] = React.useState<SharedLink | null>(null);
4737
const [sharingServiceProps, setSharingServiceProps] = React.useState(null);
4838
const [currentUser, setCurrentUser] = React.useState<User | null>(null);
@@ -51,6 +41,8 @@ function ContentSharingV2({
5141
const [collaboratorsData, setCollaboratorsData] = React.useState<Collaborations | null>(null);
5242
const [owner, setOwner] = React.useState({ id: '', email: '', name: '' });
5343

44+
const { formatMessage } = useIntl();
45+
const { addNotification } = useNotification();
5446
const { sharingService } = useSharingService({
5547
api,
5648
avatarUrlMap,
@@ -84,8 +76,42 @@ function ContentSharingV2({
8476
setOwner({ id: ownedBy.id, email: ownedBy.login, name: ownedBy.name });
8577
}, []);
8678

79+
// Handle initial data retrieval errors
80+
const getError = React.useCallback(
81+
(error: ElementsXhrError) => {
82+
// display only one component-level notification at a time
83+
if (hasError) {
84+
return;
85+
}
86+
87+
let errorMessage;
88+
if (error.status) {
89+
errorMessage = messages[CONTENT_SHARING_ERRORS[error.status]];
90+
} else if (error.response && error.response.status) {
91+
errorMessage = messages[CONTENT_SHARING_ERRORS[error.response.status]];
92+
} else {
93+
errorMessage = messages.loadingError;
94+
}
95+
96+
if (!errorMessage) {
97+
errorMessage = messages.defaultErrorNoticeText;
98+
}
99+
100+
setHasError(true);
101+
addNotification({
102+
closeButtonAriaLabel: formatMessage(messages.noticeCloseLabel),
103+
sensitivity: 'foreground' as const,
104+
typeIconAriaLabel: formatMessage(messages.errorNoticeIcon),
105+
variant: 'error',
106+
styledText: formatMessage(errorMessage),
107+
});
108+
},
109+
[hasError, addNotification, formatMessage],
110+
);
111+
87112
// Reset state if the API has changed
88113
React.useEffect(() => {
114+
setHasError(false);
89115
setItem(null);
90116
setSharedLink(null);
91117
setCurrentUser(null);
@@ -100,10 +126,14 @@ function ContentSharingV2({
100126
if (!api || isEmpty(api) || item) return;
101127

102128
(async () => {
103-
const itemData = await fetchItem({ api, itemId, itemType });
104-
handleGetItemSuccess(itemData);
129+
try {
130+
const itemData = await fetchItem({ api, itemId, itemType });
131+
handleGetItemSuccess(itemData);
132+
} catch (error) {
133+
getError(error);
134+
}
105135
})();
106-
}, [api, item, itemId, itemType, sharedLink, handleGetItemSuccess]);
136+
}, [api, item, itemId, itemType, sharedLink, handleGetItemSuccess, getError]);
107137

108138
// Get current user
109139
React.useEffect(() => {
@@ -122,10 +152,14 @@ function ContentSharingV2({
122152
};
123153

124154
(async () => {
125-
const userData = await fetchCurrentUser({ api, itemId });
126-
getUserSuccess(userData);
155+
try {
156+
const userData = await fetchCurrentUser({ api, itemId });
157+
getUserSuccess(userData);
158+
} catch (error) {
159+
getError(error);
160+
}
127161
})();
128-
}, [api, currentUser, item, itemId, itemType, sharedLink]);
162+
}, [api, currentUser, item, itemId, itemType, sharedLink, getError]);
129163

130164
// Get collaborators
131165
React.useEffect(() => {
@@ -176,24 +210,20 @@ function ContentSharingV2({
176210
const config = { sharedLinkEmail: false };
177211

178212
return (
179-
<Internationalize language={language} messages={messages}>
180-
<Providers hasProviders={hasProviders}>
181-
{item && (
182-
<UnifiedShareModal
183-
config={config}
184-
collaborationRoles={collaborationRoles}
185-
collaborators={collaborators}
186-
contactService={contactService}
187-
currentUser={currentUser}
188-
item={item}
189-
sharedLink={sharedLink}
190-
sharingService={sharingService}
191-
>
192-
{children}
193-
</UnifiedShareModal>
194-
)}
195-
</Providers>
196-
</Internationalize>
213+
item && (
214+
<UnifiedShareModal
215+
config={config}
216+
collaborationRoles={collaborationRoles}
217+
collaborators={collaborators}
218+
contactService={contactService}
219+
currentUser={currentUser}
220+
item={item}
221+
sharedLink={sharedLink}
222+
sharingService={sharingService}
223+
>
224+
{children}
225+
</UnifiedShareModal>
226+
)
197227
);
198228
}
199229

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

Lines changed: 82 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React from 'react';
22
import { render, type RenderResult, screen, waitFor } from '@testing-library/react';
3-
3+
import { Notification, TooltipProvider } from '@box/blueprint-web';
44
import { useSharingService } from '../hooks/useSharingService';
55
import {
66
DEFAULT_ITEM_API_RESPONSE,
@@ -12,7 +12,6 @@ import {
1212
mockAvatarURLMap,
1313
} from '../utils/__mocks__/ContentSharingV2Mocks';
1414
import { CONTENT_SHARING_ITEM_FIELDS } from '../constants';
15-
1615
import ContentSharingV2 from '../ContentSharingV2';
1716

1817
const createApiMock = (fileApi, folderApi, usersApi, collaborationsApi) => ({
@@ -47,19 +46,22 @@ const defaultApiMock = createApiMock(
4746
{ getCollaborations: getCollaborationsMock },
4847
);
4948

49+
const mockAddNotification = jest.fn();
50+
jest.mock('@box/blueprint-web', () => ({
51+
...jest.requireActual('@box/blueprint-web'),
52+
useNotification: jest.fn(() => ({ addNotification: mockAddNotification })),
53+
}));
5054
jest.mock('../hooks/useSharingService', () => ({
5155
useSharingService: jest.fn().mockReturnValue({ sharingService: null }),
5256
}));
5357

5458
const renderComponent = (props = {}): RenderResult =>
5559
render(
56-
<ContentSharingV2
57-
api={defaultApiMock}
58-
itemId={MOCK_ITEM.id}
59-
itemType={MOCK_ITEM.type}
60-
hasProviders={true}
61-
{...props}
62-
/>,
60+
<Notification.Provider>
61+
<TooltipProvider>
62+
<ContentSharingV2 api={defaultApiMock} itemId={MOCK_ITEM.id} itemType={MOCK_ITEM.type} {...props} />
63+
</TooltipProvider>
64+
</Notification.Provider>,
6365
);
6466

6567
describe('elements/content-sharing/ContentSharingV2', () => {
@@ -73,9 +75,6 @@ describe('elements/content-sharing/ContentSharingV2', () => {
7375
expect(getDefaultFileMock).toHaveBeenCalledWith(MOCK_ITEM.id, expect.any(Function), expect.any(Function), {
7476
fields: CONTENT_SHARING_ITEM_FIELDS,
7577
});
76-
expect(screen.getByRole('heading', { name: /Box Development Guide.pdf/i })).toBeVisible();
77-
expect(screen.getByRole('combobox', { name: 'Invite People' })).toBeVisible();
78-
expect(screen.getByRole('switch', { name: 'Shared link' })).toBeVisible();
7978
});
8079
});
8180

@@ -170,4 +169,75 @@ describe('elements/content-sharing/ContentSharingV2', () => {
170169
expect(screen.getByRole('heading', { name: /Box Development Guide.pdf/i })).toBeVisible();
171170
});
172171
});
172+
173+
describe('getError function', () => {
174+
const createErrorApi = error => ({
175+
...defaultApiMock,
176+
getFileAPI: jest.fn().mockReturnValue({
177+
getFile: jest.fn().mockImplementation((id, successFn, errorFn) => {
178+
errorFn(error);
179+
}),
180+
}),
181+
});
182+
183+
test('should render bad request message for error.status 400', async () => {
184+
const error = { status: 400 };
185+
renderComponent({ api: createErrorApi(error) });
186+
187+
await waitFor(() => {
188+
expect(mockAddNotification).toHaveBeenCalledWith({
189+
closeButtonAriaLabel: 'Close',
190+
sensitivity: 'foreground',
191+
styledText: 'The request for this item was malformed.',
192+
typeIconAriaLabel: 'Error',
193+
variant: 'error',
194+
});
195+
});
196+
});
197+
198+
test('should render no access message for error.response.status 401', async () => {
199+
const error = { response: { status: 401 } };
200+
renderComponent({ api: createErrorApi(error) });
201+
202+
await waitFor(() => {
203+
expect(mockAddNotification).toHaveBeenCalledWith({
204+
closeButtonAriaLabel: 'Close',
205+
sensitivity: 'foreground',
206+
styledText: 'You do not have access to this item.',
207+
typeIconAriaLabel: 'Error',
208+
variant: 'error',
209+
});
210+
});
211+
});
212+
213+
test('should render loading error message when no status is provided', async () => {
214+
const error = { message: 'Network error' };
215+
renderComponent({ api: createErrorApi(error) });
216+
217+
await waitFor(() => {
218+
expect(mockAddNotification).toHaveBeenCalledWith({
219+
closeButtonAriaLabel: 'Close',
220+
sensitivity: 'foreground',
221+
styledText: 'Could not load shared link for this item.',
222+
typeIconAriaLabel: 'Error',
223+
variant: 'error',
224+
});
225+
});
226+
});
227+
228+
test('should render default error message when no corresponding error status is provided', async () => {
229+
const error = { status: 503 };
230+
renderComponent({ api: createErrorApi(error) });
231+
232+
await waitFor(() => {
233+
expect(mockAddNotification).toHaveBeenCalledWith({
234+
closeButtonAriaLabel: 'Close',
235+
sensitivity: 'foreground',
236+
styledText: 'Something went wrong. Please try again later.',
237+
typeIconAriaLabel: 'Error',
238+
variant: 'error',
239+
});
240+
});
241+
});
242+
});
173243
});

src/elements/content-sharing/messages.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { defineMessages } from 'react-intl';
22

33
const messages = defineMessages({
4+
defaultErrorNoticeText: {
5+
defaultMessage: 'Something went wrong. Please try again later.',
6+
description: 'Default error notification text rendered when API fails',
7+
id: 'be.contentSharing.defaultErrorNoticeText',
8+
},
49
badRequestError: {
510
defaultMessage: 'The request for this item was malformed.',
611
description: 'Message that appears when the request for the ContentSharing Element is malformed.',
@@ -78,6 +83,16 @@ const messages = defineMessages({
7883
description: 'Display text for a Group contact type',
7984
id: 'be.contentSharing.groupContactLabel',
8085
},
86+
noticeCloseLabel: {
87+
defaultMessage: 'Close',
88+
description: 'Close button aria label for the notifications',
89+
id: 'be.contentSharing.noticeCloseLabel',
90+
},
91+
errorNoticeIcon: {
92+
defaultMessage: 'Error',
93+
description: 'Icon label for the error notifications',
94+
id: 'be.contentSharing.errorNoticeIcon',
95+
},
8196
});
8297

8398
export default messages;

0 commit comments

Comments
 (0)