diff --git a/i18n/en-US.properties b/i18n/en-US.properties
index a4b9ee8fed..cb13434219 100644
--- a/i18n/en-US.properties
+++ b/i18n/en-US.properties
@@ -138,6 +138,10 @@ be.contentInsights.trendYear = PAST YEAR
be.contentSharing.badRequestError = The request for this item was malformed.
# Message that appears when collaborators cannot be retrieved in the ContentSharing Element.
be.contentSharing.collaboratorsLoadingError = Could not retrieve collaborators for this item.
+# Default error notification text rendered when API fails
+be.contentSharing.defaultErrorNoticeText = Something went wrong. Please try again later.
+# Icon label for the error notifications
+be.contentSharing.errorNoticeIcon = Error
# Message that appears when users cannot be retrieved in the ContentSharing Element.
be.contentSharing.getContactsError = Could not retrieve contacts.
# Display text for a Group contact type
@@ -148,6 +152,8 @@ be.contentSharing.loadingError = Could not load shared link for this item.
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.
+# Close button aria label for the notifications
+be.contentSharing.noticeCloseLabel = Close
# 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.
diff --git a/src/elements/content-sharing/ContentSharing.js b/src/elements/content-sharing/ContentSharing.js
index 9999adf84d..5c425753b8 100644
--- a/src/elements/content-sharing/ContentSharing.js
+++ b/src/elements/content-sharing/ContentSharing.js
@@ -12,6 +12,8 @@ import API from '../../api';
// $FlowFixMe
import { withBlueprintModernization } from '../common/withBlueprintModernization';
import { isFeatureEnabled } from '../common/feature-checking';
+import Internationalize from '../common/Internationalize';
+import Providers from '../common/Providers';
import SharingModal from './SharingModal';
// $FlowFixMe
import ContentSharingV2 from './ContentSharingV2';
@@ -117,16 +119,13 @@ function ContentSharing({
if (isFeatureEnabled(features, 'contentSharingV2')) {
return (
api && (
-
- {children}
-
+
+
+
+ {children}
+
+
+
)
);
}
diff --git a/src/elements/content-sharing/ContentSharingV2.tsx b/src/elements/content-sharing/ContentSharingV2.tsx
index 8f5dc7eef4..2f076b0838 100644
--- a/src/elements/content-sharing/ContentSharingV2.tsx
+++ b/src/elements/content-sharing/ContentSharingV2.tsx
@@ -1,20 +1,23 @@
import * as React from 'react';
+import { useIntl } from 'react-intl';
import isEmpty from 'lodash/isEmpty';
-
+import { useNotification } from '@box/blueprint-web';
import { UnifiedShareModal } from '@box/unified-share-modal';
import type { CollaborationRole, Collaborator, Item, SharedLink, User } from '@box/unified-share-modal';
import API from '../../api';
-import Internationalize from '../common/Internationalize';
-import Providers from '../common/Providers';
import { withBlueprintModernization } from '../common/withBlueprintModernization';
import { fetchAvatars, fetchCollaborators, fetchCurrentUser, fetchItem } from './apis';
+import { CONTENT_SHARING_ERRORS } from './constants';
import { useContactService, useSharingService } from './hooks';
import { convertCollabsResponse, convertItemResponse } from './utils';
-import type { Collaborations, ItemType, StringMap } from '../../common/types/core';
+import type { Collaborations, ItemType } from '../../common/types/core';
+import type { ElementsXhrError } from '../../common/types/api';
import type { AvatarURLMap } from './types';
+import messages from './messages';
+
export interface ContentSharingV2Props {
/** api - API instance */
api: API;
@@ -24,25 +27,12 @@ export interface ContentSharingV2Props {
itemId: string;
/** itemType - "file" or "folder" */
itemType: ItemType;
- /** hasProviders - Whether the element has providers for USM already */
- hasProviders?: boolean;
- /** language - Language used for the element */
- language?: string;
- /** messages - Localized strings used by the element */
- messages?: StringMap;
}
-function ContentSharingV2({
- api,
- children,
- itemId,
- itemType,
- hasProviders,
- language,
- messages,
-}: ContentSharingV2Props) {
+function ContentSharingV2({ api, children, itemId, itemType }: ContentSharingV2Props) {
const [avatarUrlMap, setAvatarUrlMap] = React.useState(null);
const [item, setItem] = React.useState- (null);
+ const [hasError, setHasError] = React.useState(false);
const [sharedLink, setSharedLink] = React.useState(null);
const [sharingServiceProps, setSharingServiceProps] = React.useState(null);
const [currentUser, setCurrentUser] = React.useState(null);
@@ -51,6 +41,8 @@ function ContentSharingV2({
const [collaboratorsData, setCollaboratorsData] = React.useState(null);
const [owner, setOwner] = React.useState({ id: '', email: '', name: '' });
+ const { formatMessage } = useIntl();
+ const { addNotification } = useNotification();
const { sharingService } = useSharingService({
api,
avatarUrlMap,
@@ -84,8 +76,42 @@ function ContentSharingV2({
setOwner({ id: ownedBy.id, email: ownedBy.login, name: ownedBy.name });
}, []);
+ // Handle initial data retrieval errors
+ const getError = React.useCallback(
+ (error: ElementsXhrError) => {
+ // display only one component-level notification at a time
+ if (hasError) {
+ return;
+ }
+
+ let errorMessage;
+ if (error.status) {
+ errorMessage = messages[CONTENT_SHARING_ERRORS[error.status]];
+ } else if (error.response && error.response.status) {
+ errorMessage = messages[CONTENT_SHARING_ERRORS[error.response.status]];
+ } else {
+ errorMessage = messages.loadingError;
+ }
+
+ if (!errorMessage) {
+ errorMessage = messages.defaultErrorNoticeText;
+ }
+
+ setHasError(true);
+ addNotification({
+ closeButtonAriaLabel: formatMessage(messages.noticeCloseLabel),
+ sensitivity: 'foreground' as const,
+ typeIconAriaLabel: formatMessage(messages.errorNoticeIcon),
+ variant: 'error',
+ styledText: formatMessage(errorMessage),
+ });
+ },
+ [hasError, addNotification, formatMessage],
+ );
+
// Reset state if the API has changed
React.useEffect(() => {
+ setHasError(false);
setItem(null);
setSharedLink(null);
setCurrentUser(null);
@@ -100,10 +126,14 @@ function ContentSharingV2({
if (!api || isEmpty(api) || item) return;
(async () => {
- const itemData = await fetchItem({ api, itemId, itemType });
- handleGetItemSuccess(itemData);
+ try {
+ const itemData = await fetchItem({ api, itemId, itemType });
+ handleGetItemSuccess(itemData);
+ } catch (error) {
+ getError(error);
+ }
})();
- }, [api, item, itemId, itemType, sharedLink, handleGetItemSuccess]);
+ }, [api, item, itemId, itemType, sharedLink, handleGetItemSuccess, getError]);
// Get current user
React.useEffect(() => {
@@ -122,10 +152,14 @@ function ContentSharingV2({
};
(async () => {
- const userData = await fetchCurrentUser({ api, itemId });
- getUserSuccess(userData);
+ try {
+ const userData = await fetchCurrentUser({ api, itemId });
+ getUserSuccess(userData);
+ } catch (error) {
+ getError(error);
+ }
})();
- }, [api, currentUser, item, itemId, itemType, sharedLink]);
+ }, [api, currentUser, item, itemId, itemType, sharedLink, getError]);
// Get collaborators
React.useEffect(() => {
@@ -176,24 +210,20 @@ function ContentSharingV2({
const config = { sharedLinkEmail: false };
return (
-
-
- {item && (
-
- {children}
-
- )}
-
-
+ item && (
+
+ {children}
+
+ )
);
}
diff --git a/src/elements/content-sharing/__tests__/ContentSharingV2.test.tsx b/src/elements/content-sharing/__tests__/ContentSharingV2.test.tsx
index 3b267bb6bd..23139057cb 100644
--- a/src/elements/content-sharing/__tests__/ContentSharingV2.test.tsx
+++ b/src/elements/content-sharing/__tests__/ContentSharingV2.test.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import { render, type RenderResult, screen, waitFor } from '@testing-library/react';
-
+import { Notification, TooltipProvider } from '@box/blueprint-web';
import { useSharingService } from '../hooks/useSharingService';
import {
DEFAULT_ITEM_API_RESPONSE,
@@ -12,7 +12,6 @@ import {
mockAvatarURLMap,
} from '../utils/__mocks__/ContentSharingV2Mocks';
import { CONTENT_SHARING_ITEM_FIELDS } from '../constants';
-
import ContentSharingV2 from '../ContentSharingV2';
const createApiMock = (fileApi, folderApi, usersApi, collaborationsApi) => ({
@@ -47,19 +46,22 @@ const defaultApiMock = createApiMock(
{ getCollaborations: getCollaborationsMock },
);
+const mockAddNotification = jest.fn();
+jest.mock('@box/blueprint-web', () => ({
+ ...jest.requireActual('@box/blueprint-web'),
+ useNotification: jest.fn(() => ({ addNotification: mockAddNotification })),
+}));
jest.mock('../hooks/useSharingService', () => ({
useSharingService: jest.fn().mockReturnValue({ sharingService: null }),
}));
const renderComponent = (props = {}): RenderResult =>
render(
- ,
+
+
+
+
+ ,
);
describe('elements/content-sharing/ContentSharingV2', () => {
@@ -73,9 +75,6 @@ describe('elements/content-sharing/ContentSharingV2', () => {
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();
});
});
@@ -170,4 +169,75 @@ describe('elements/content-sharing/ContentSharingV2', () => {
expect(screen.getByRole('heading', { name: /Box Development Guide.pdf/i })).toBeVisible();
});
});
+
+ describe('getError function', () => {
+ const createErrorApi = error => ({
+ ...defaultApiMock,
+ getFileAPI: jest.fn().mockReturnValue({
+ getFile: jest.fn().mockImplementation((id, successFn, errorFn) => {
+ errorFn(error);
+ }),
+ }),
+ });
+
+ test('should render bad request message for error.status 400', async () => {
+ const error = { status: 400 };
+ renderComponent({ api: createErrorApi(error) });
+
+ await waitFor(() => {
+ expect(mockAddNotification).toHaveBeenCalledWith({
+ closeButtonAriaLabel: 'Close',
+ sensitivity: 'foreground',
+ styledText: 'The request for this item was malformed.',
+ typeIconAriaLabel: 'Error',
+ variant: 'error',
+ });
+ });
+ });
+
+ test('should render no access message for error.response.status 401', async () => {
+ const error = { response: { status: 401 } };
+ renderComponent({ api: createErrorApi(error) });
+
+ await waitFor(() => {
+ expect(mockAddNotification).toHaveBeenCalledWith({
+ closeButtonAriaLabel: 'Close',
+ sensitivity: 'foreground',
+ styledText: 'You do not have access to this item.',
+ typeIconAriaLabel: 'Error',
+ variant: 'error',
+ });
+ });
+ });
+
+ test('should render loading error message when no status is provided', async () => {
+ const error = { message: 'Network error' };
+ renderComponent({ api: createErrorApi(error) });
+
+ await waitFor(() => {
+ expect(mockAddNotification).toHaveBeenCalledWith({
+ closeButtonAriaLabel: 'Close',
+ sensitivity: 'foreground',
+ styledText: 'Could not load shared link for this item.',
+ typeIconAriaLabel: 'Error',
+ variant: 'error',
+ });
+ });
+ });
+
+ test('should render default error message when no corresponding error status is provided', async () => {
+ const error = { status: 503 };
+ renderComponent({ api: createErrorApi(error) });
+
+ await waitFor(() => {
+ expect(mockAddNotification).toHaveBeenCalledWith({
+ closeButtonAriaLabel: 'Close',
+ sensitivity: 'foreground',
+ styledText: 'Something went wrong. Please try again later.',
+ typeIconAriaLabel: 'Error',
+ variant: 'error',
+ });
+ });
+ });
+ });
});
diff --git a/src/elements/content-sharing/messages.js b/src/elements/content-sharing/messages.js
index 325043856a..98135e1039 100644
--- a/src/elements/content-sharing/messages.js
+++ b/src/elements/content-sharing/messages.js
@@ -1,6 +1,11 @@
import { defineMessages } from 'react-intl';
const messages = defineMessages({
+ defaultErrorNoticeText: {
+ defaultMessage: 'Something went wrong. Please try again later.',
+ description: 'Default error notification text rendered when API fails',
+ id: 'be.contentSharing.defaultErrorNoticeText',
+ },
badRequestError: {
defaultMessage: 'The request for this item was malformed.',
description: 'Message that appears when the request for the ContentSharing Element is malformed.',
@@ -78,6 +83,16 @@ const messages = defineMessages({
description: 'Display text for a Group contact type',
id: 'be.contentSharing.groupContactLabel',
},
+ noticeCloseLabel: {
+ defaultMessage: 'Close',
+ description: 'Close button aria label for the notifications',
+ id: 'be.contentSharing.noticeCloseLabel',
+ },
+ errorNoticeIcon: {
+ defaultMessage: 'Error',
+ description: 'Icon label for the error notifications',
+ id: 'be.contentSharing.errorNoticeIcon',
+ },
});
export default messages;
diff --git a/src/elements/content-sharing/stories/ContentSharing.stories.js b/src/elements/content-sharing/stories/ContentSharing.stories.js
index c53757d03a..1ccba911af 100644
--- a/src/elements/content-sharing/stories/ContentSharing.stories.js
+++ b/src/elements/content-sharing/stories/ContentSharing.stories.js
@@ -20,6 +20,7 @@ export const withContentSharingV2Enabled = {
...global.FEATURE_FLAGS,
contentSharingV2: true,
},
+ hasProviders: true,
},
};
diff --git a/src/elements/content-sharing/stories/ContentSharingV2.stories.tsx b/src/elements/content-sharing/stories/ContentSharingV2.stories.tsx
index 9e663a277f..eca25b96bd 100644
--- a/src/elements/content-sharing/stories/ContentSharingV2.stories.tsx
+++ b/src/elements/content-sharing/stories/ContentSharingV2.stories.tsx
@@ -1,8 +1,5 @@
-import * as React from 'react';
-
import { TYPE_FILE, TYPE_FOLDER } from '../../../constants';
-import { mockApiWithSharedLink, mockApiWithoutSharedLink } from '../utils/__mocks__/ContentSharingV2Mocks';
-import ContentSharingV2 from '../ContentSharingV2';
+import { ContentSharingV2Template, mockApiWithSharedLink } from '../utils/__mocks__/ContentSharingV2Mocks';
export const basic = {};
@@ -14,13 +11,7 @@ export const withSharedLink = {
export default {
title: 'Elements/ContentSharingV2',
- component: ContentSharingV2,
- args: {
- api: mockApiWithoutSharedLink,
- children: ,
- itemType: TYPE_FILE,
- itemId: global.FILE_ID,
- },
+ component: ContentSharingV2Template,
argTypes: {
itemType: {
options: [TYPE_FILE, TYPE_FOLDER],
diff --git a/src/elements/content-sharing/stories/tests/ContentSharingV2-visual.stories.tsx b/src/elements/content-sharing/stories/tests/ContentSharingV2-visual.stories.tsx
index d9b42c109b..8e951282fe 100644
--- a/src/elements/content-sharing/stories/tests/ContentSharingV2-visual.stories.tsx
+++ b/src/elements/content-sharing/stories/tests/ContentSharingV2-visual.stories.tsx
@@ -1,15 +1,11 @@
-import * as React from 'react';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
-import { Button } from '@box/blueprint-web';
-
-import { TYPE_FILE } from '../../../../constants';
import {
+ ContentSharingV2Template,
mockApiWithCollaborators,
mockApiWithSharedLink,
mockApiWithoutSharedLink,
} from '../../utils/__mocks__/ContentSharingV2Mocks';
-import ContentSharingV2 from '../../ContentSharingV2';
export const withModernization = {
args: {
@@ -69,10 +65,5 @@ export const withCollaborators = {
export default {
title: 'Elements/ContentSharingV2/tests/visual-regression-tests',
- component: ContentSharingV2,
- args: {
- children: ,
- itemType: TYPE_FILE,
- itemId: global.FILE_ID,
- },
+ component: ContentSharingV2Template,
};
diff --git a/src/elements/content-sharing/utils/__mocks__/ContentSharingV2Mocks.js b/src/elements/content-sharing/utils/__mocks__/ContentSharingV2Mocks.js
index 14c9787b46..706fd5201e 100644
--- a/src/elements/content-sharing/utils/__mocks__/ContentSharingV2Mocks.js
+++ b/src/elements/content-sharing/utils/__mocks__/ContentSharingV2Mocks.js
@@ -1,3 +1,10 @@
+import * as React from 'react';
+
+import { Button, Notification, TooltipProvider } from '@box/blueprint-web';
+
+import { TYPE_FILE } from '../../../../constants';
+import ContentSharingV2 from '../../ContentSharingV2';
+
export const MOCK_PERMISSIONS = {
can_download: true,
can_invite_collaborator: true,
@@ -193,3 +200,20 @@ export const mockApiWithCollaborators = createMockApi(
DEFAULT_USER_API_RESPONSE,
MOCK_COLLABORATIONS_RESPONSE,
);
+
+export const ContentSharingV2Template = (props = {}) => {
+ return (
+
+
+
+
+
+
+
+ );
+};