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
6 changes: 6 additions & 0 deletions i18n/en-US.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
19 changes: 9 additions & 10 deletions src/elements/content-sharing/ContentSharing.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -117,16 +119,13 @@ function ContentSharing({
if (isFeatureEnabled(features, 'contentSharingV2')) {
return (
api && (
<ContentSharingV2
api={api}
itemId={itemID}
itemType={itemType}
hasProviders={hasProviders}
language={language}
messages={messages}
>
{children}
</ContentSharingV2>
<Internationalize language={language} messages={messages}>
<Providers hasProviders={hasProviders}>
<ContentSharingV2 api={api} itemId={itemID} itemType={itemType}>
{children}
</ContentSharingV2>
</Providers>
</Internationalize>
)
);
}
Expand Down
116 changes: 73 additions & 43 deletions src/elements/content-sharing/ContentSharingV2.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<AvatarURLMap | null>(null);
const [item, setItem] = React.useState<Item | null>(null);
const [hasError, setHasError] = React.useState<boolean>(false);
const [sharedLink, setSharedLink] = React.useState<SharedLink | null>(null);
const [sharingServiceProps, setSharingServiceProps] = React.useState(null);
const [currentUser, setCurrentUser] = React.useState<User | null>(null);
Expand All @@ -51,6 +41,8 @@ function ContentSharingV2({
const [collaboratorsData, setCollaboratorsData] = React.useState<Collaborations | null>(null);
const [owner, setOwner] = React.useState({ id: '', email: '', name: '' });

const { formatMessage } = useIntl();
const { addNotification } = useNotification();
const { sharingService } = useSharingService({
api,
avatarUrlMap,
Expand Down Expand Up @@ -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);
Expand All @@ -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(() => {
Expand All @@ -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(() => {
Expand Down Expand Up @@ -176,24 +210,20 @@ function ContentSharingV2({
const config = { sharedLinkEmail: false };

return (
<Internationalize language={language} messages={messages}>
<Providers hasProviders={hasProviders}>
{item && (
<UnifiedShareModal
config={config}
collaborationRoles={collaborationRoles}
collaborators={collaborators}
contactService={contactService}
currentUser={currentUser}
item={item}
sharedLink={sharedLink}
sharingService={sharingService}
>
{children}
</UnifiedShareModal>
)}
</Providers>
</Internationalize>
item && (
<UnifiedShareModal
config={config}
collaborationRoles={collaborationRoles}
collaborators={collaborators}
contactService={contactService}
currentUser={currentUser}
item={item}
sharedLink={sharedLink}
sharingService={sharingService}
>
{children}
</UnifiedShareModal>
)
);
}

Expand Down
94 changes: 82 additions & 12 deletions src/elements/content-sharing/__tests__/ContentSharingV2.test.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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) => ({
Expand Down Expand Up @@ -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(
<ContentSharingV2
api={defaultApiMock}
itemId={MOCK_ITEM.id}
itemType={MOCK_ITEM.type}
hasProviders={true}
{...props}
/>,
<Notification.Provider>
<TooltipProvider>
<ContentSharingV2 api={defaultApiMock} itemId={MOCK_ITEM.id} itemType={MOCK_ITEM.type} {...props} />
</TooltipProvider>
</Notification.Provider>,
);

describe('elements/content-sharing/ContentSharingV2', () => {
Expand All @@ -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();
});
});

Expand Down Expand Up @@ -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',
});
});
});
});
});
15 changes: 15 additions & 0 deletions src/elements/content-sharing/messages.js
Original file line number Diff line number Diff line change
@@ -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.',
Expand Down Expand Up @@ -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;
Loading