Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
4 changes: 4 additions & 0 deletions i18n/en-US.properties
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ 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.
# 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 +150,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
149 changes: 90 additions & 59 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,38 @@ 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 errorObject;
if (error.status) {
errorObject = messages[CONTENT_SHARING_ERRORS[error.status]];
} else if (error.response && error.response.status) {
errorObject = messages[CONTENT_SHARING_ERRORS[error.response.status]];
} else {
errorObject = messages.loadingError;
}

setHasError(true);
addNotification({
closeButtonAriaLabel: formatMessage(messages.noticeCloseLabel),
sensitivity: 'foreground' as const,
typeIconAriaLabel: formatMessage(messages.errorNoticeIcon),
variant: 'error',
styledText: formatMessage(errorObject),
});
},
[hasError, addNotification, formatMessage],
);

// Reset state if the API has changed
React.useEffect(() => {
setHasError(false);
setItem(null);
setSharedLink(null);
setCurrentUser(null);
Expand All @@ -100,10 +122,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 +148,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 All @@ -135,31 +165,36 @@ function ContentSharingV2({
try {
const response = await fetchCollaborators({ api, itemId, itemType });
setCollaboratorsData(response);
} catch {
} catch (error) {
setCollaboratorsData({ entries: [], next_marker: null });
getError(error);
}
})();
}, [api, collaboratorsData, item, itemId, itemType]);
}, [api, collaboratorsData, item, itemId, itemType, getError]);

// Get avatars when collaborators are available
React.useEffect(() => {
if (avatarUrlMap || !collaboratorsData || !collaboratorsData.entries || !owner.id) return;
(async () => {
const ownerEntry = {
accessible_by: {
id: owner.id,
login: owner.email,
name: owner.name,
},
};
const response = await fetchAvatars({
api,
itemId,
collaborators: [...collaboratorsData.entries, ownerEntry],
});
setAvatarUrlMap(response);
try {
const ownerEntry = {
accessible_by: {
id: owner.id,
login: owner.email,
name: owner.name,
},
};
const response = await fetchAvatars({
api,
itemId,
collaborators: [...collaboratorsData.entries, ownerEntry],
});
setAvatarUrlMap(response);
} catch (error) {
getError(error);
}
})();
}, [api, avatarUrlMap, collaboratorsData, itemId, owner]);
}, [api, avatarUrlMap, collaboratorsData, itemId, owner, getError]);

React.useEffect(() => {
if (avatarUrlMap && collaboratorsData && currentUser && owner) {
Expand All @@ -176,24 +211,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
79 changes: 67 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,60 @@ 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',
});
});
});
});
});
Loading