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 ( + + + + + + + + ); +};