diff --git a/src/elements/content-uploader/ContentUploader.js b/src/elements/content-uploader/ContentUploader.js index c62e9e7530..52e4f83929 100644 --- a/src/elements/content-uploader/ContentUploader.js +++ b/src/elements/content-uploader/ContentUploader.js @@ -11,6 +11,7 @@ import getProp from 'lodash/get'; import noop from 'lodash/noop'; import uniqueid from 'lodash/uniqueId'; import cloneDeep from 'lodash/cloneDeep'; +import { TooltipProvider } from '@box/blueprint-web'; import { getTypedFileId, getTypedFolderId } from '../../utils/file'; import Browser from '../../utils/Browser'; @@ -1269,46 +1270,48 @@ class ContentUploader extends Component { return ( - {useUploadsManager ? ( -
- -
- ) : ( -
- -
-
- )} + + {useUploadsManager ? ( +
+ +
+ ) : ( +
+ +
+
+ )} +
); } diff --git a/src/elements/content-uploader/ItemAction.js b/src/elements/content-uploader/ItemAction.js.flow similarity index 51% rename from src/elements/content-uploader/ItemAction.js rename to src/elements/content-uploader/ItemAction.js.flow index e8d0158506..db04a57f66 100644 --- a/src/elements/content-uploader/ItemAction.js +++ b/src/elements/content-uploader/ItemAction.js.flow @@ -1,20 +1,11 @@ -/** - * @flow - * @file Item action component displayed on the upload toast, e.g. cancel/resume - */ - import * as React from 'react'; -import { FormattedMessage, injectIntl } from 'react-intl'; +import { injectIntl } from 'react-intl'; import type { IntlShape } from 'react-intl'; -import IconCheck from '../../icons/general/IconCheck'; -import IconClose from '../../icons/general/IconClose'; -import IconInProgress from './IconInProgress'; -import IconRetry from '../../icons/general/IconRetry'; -import LoadingIndicator from '../../components/loading-indicator'; -import PlainButton from '../../components/plain-button/PlainButton'; -import PrimaryButton from '../../components/primary-button/PrimaryButton'; -import Tooltip from '../../components/tooltip'; -import messages from '../common/messages'; +import { Button, IconButton, LoadingIndicator } from '@box/blueprint-web'; +import { ArrowCurveForward, Checkmark } from '@box/blueprint-web-assets/icons/Line'; +import { EllipsisBadge, XMark } from '@box/blueprint-web-assets/icons/Fill'; + +import Tooltip, { TooltipPosition } from '../../components/tooltip'; import { ERROR_CODE_UPLOAD_FILE_SIZE_LIMIT_EXCEEDED, STATUS_PENDING, @@ -23,34 +14,60 @@ import { STATUS_COMPLETE, STATUS_ERROR, } from '../../constants'; + +import messages from '../common/messages'; + import type { UploadStatus } from '../../common/types/upload'; import './ItemAction.scss'; +import { Size5, SurfaceStatusSurfaceSuccess } from '@box/blueprint-web-assets/tokens/tokens'; -const ICON_CHECK_COLOR = '#26C281'; - -type Props = { - error?: Object, +type ItemActionProps = { + error?: any, intl: IntlShape, isFolder?: boolean, isResumableUploadsEnabled: boolean, - onClick: Function, - onUpgradeCTAClick?: Function, + onClick: any, + onUpgradeCTAClick?: any, status: UploadStatus, }; +const getIconWithTooltip = ( + icon: React.ReactNode, + isDisabled: boolean, + isLoading: boolean, + onClick: any, + tooltip: boolean, + tooltipText: string, +) => { + if (isLoading) { + return ; + } + + if (tooltip) { + return ( + + icon} /> + + ); + } + + return <>{icon}; +}; + const ItemAction = ({ - error = {}, + error, intl, isFolder = false, isResumableUploadsEnabled, onClick, onUpgradeCTAClick, status, -}: Props) => { - let icon = ; +}: ItemActionProps) => { + let icon: React.ReactNode = ; let tooltip; - const { code } = error; + let isLoading = false; + const { code } = error || {}; const { formatMessage } = intl; if (isFolder && status !== STATUS_PENDING) { @@ -59,28 +76,28 @@ const ItemAction = ({ switch (status) { case STATUS_COMPLETE: - icon = ; + icon = ; if (!isResumableUploadsEnabled) { tooltip = messages.remove; } break; case STATUS_ERROR: - icon = ; + icon = ; tooltip = isResumableUploadsEnabled ? messages.resume : messages.retry; break; case STATUS_IN_PROGRESS: case STATUS_STAGED: if (isResumableUploadsEnabled) { - icon = ; + isLoading = true; } else { - icon = ; + icon = ; tooltip = messages.uploadsCancelButtonTooltip; } break; case STATUS_PENDING: default: if (isResumableUploadsEnabled) { - icon = ; + isLoading = true; } else { tooltip = messages.uploadsCancelButtonTooltip; } @@ -89,13 +106,13 @@ const ItemAction = ({ if (status === STATUS_ERROR && code === ERROR_CODE_UPLOAD_FILE_SIZE_LIMIT_EXCEEDED && !!onUpgradeCTAClick) { return ( - - - + {intl.formatMessage(messages.uploadsFileSizeLimitExceededUpgradeMessageForUpgradeCta)} + ); } const isDisabled = status === STATUS_STAGED; @@ -103,18 +120,9 @@ const ItemAction = ({ return (
- {tooltip ? ( - - - {icon} - - - ) : ( - icon - )} + {getIconWithTooltip(icon, isDisabled, isLoading, onClick, tooltip, tooltipText)}
); }; -export { ItemAction as ItemActionForTesting }; export default injectIntl(ItemAction); diff --git a/src/elements/content-uploader/ItemAction.scss b/src/elements/content-uploader/ItemAction.scss index 412397f4a9..c8a9ffc9aa 100644 --- a/src/elements/content-uploader/ItemAction.scss +++ b/src/elements/content-uploader/ItemAction.scss @@ -1,7 +1,4 @@ .bcu-item-action { - width: 24px; - height: 24px; - .crawler { display: flex; align-items: center; diff --git a/src/elements/content-uploader/ItemAction.tsx b/src/elements/content-uploader/ItemAction.tsx new file mode 100644 index 0000000000..1479867984 --- /dev/null +++ b/src/elements/content-uploader/ItemAction.tsx @@ -0,0 +1,126 @@ +import * as React from 'react'; +import { useIntl } from 'react-intl'; +import { AxiosError } from 'axios'; +import { Button, IconButton, LoadingIndicator, Tooltip } from '@box/blueprint-web'; +import { ArrowCurveForward, Checkmark } from '@box/blueprint-web-assets/icons/Line'; +import { EllipsisBadge, XMark } from '@box/blueprint-web-assets/icons/Fill'; +import { Size5, SurfaceStatusSurfaceSuccess } from '@box/blueprint-web-assets/tokens/tokens'; + +import { + ERROR_CODE_UPLOAD_FILE_SIZE_LIMIT_EXCEEDED, + STATUS_PENDING, + STATUS_IN_PROGRESS, + STATUS_STAGED, + STATUS_COMPLETE, + STATUS_ERROR, +} from '../../constants'; + +import messages from '../common/messages'; + +import type { UploadStatus } from '../../common/types/upload'; + +import './ItemAction.scss'; + +export interface ItemActionProps { + error?: AxiosError; + isFolder?: boolean; + isResumableUploadsEnabled: boolean; + onClick: React.MouseEventHandler; + onUpgradeCTAClick?: () => void; + status: UploadStatus; +} + +const getIconWithTooltip = ( + icon: React.ReactNode, + isDisabled: boolean, + isLoading: boolean, + onClick: React.MouseEventHandler, + tooltip: boolean, + tooltipText: string, +) => { + if (isLoading) { + return ; + } + + if (tooltip) { + return ( + + icon} /> + + ); + } + + return <>{icon}; +}; + +const ItemAction = ({ + error, + isFolder = false, + isResumableUploadsEnabled, + onClick, + onUpgradeCTAClick, + status, +}: ItemActionProps) => { + const intl = useIntl(); + let icon: React.ReactNode = ; + let tooltip; + let isLoading = false; + const { code } = error || {}; + const { formatMessage } = intl; + + if (isFolder && status !== STATUS_PENDING) { + return null; + } + + switch (status) { + case STATUS_COMPLETE: + icon = ; + if (!isResumableUploadsEnabled) { + tooltip = messages.remove; + } + break; + case STATUS_ERROR: + icon = ; + tooltip = isResumableUploadsEnabled ? messages.resume : messages.retry; + break; + case STATUS_IN_PROGRESS: + case STATUS_STAGED: + if (isResumableUploadsEnabled) { + isLoading = true; + } else { + icon = ; + tooltip = messages.uploadsCancelButtonTooltip; + } + break; + case STATUS_PENDING: + default: + if (isResumableUploadsEnabled) { + isLoading = true; + } else { + tooltip = messages.uploadsCancelButtonTooltip; + } + break; + } + + if (status === STATUS_ERROR && code === ERROR_CODE_UPLOAD_FILE_SIZE_LIMIT_EXCEEDED && !!onUpgradeCTAClick) { + return ( + + ); + } + const isDisabled = status === STATUS_STAGED; + const tooltipText = tooltip && formatMessage(tooltip); + + return ( +
+ {getIconWithTooltip(icon, isDisabled, isLoading, onClick, tooltip, tooltipText)} +
+ ); +}; + +export default ItemAction; diff --git a/src/elements/content-uploader/ItemList.js b/src/elements/content-uploader/ItemList.js index 668fa46520..cf189eac3f 100644 --- a/src/elements/content-uploader/ItemList.js +++ b/src/elements/content-uploader/ItemList.js @@ -36,6 +36,7 @@ const ItemList = ({ const progressCell = progressCellRenderer(!!onUpgradeCTAClick); const actionCell = actionCellRenderer(isResumableUploadsEnabled, onClick, onUpgradeCTAClick); const removeCell = removeCellRenderer(onRemoveClick); + const baseIconWidth = 32; return ( {isResumableUploadsEnabled && ( )}
diff --git a/src/elements/content-uploader/__tests__/ItemAction.test.js b/src/elements/content-uploader/__tests__/ItemAction.test.js deleted file mode 100644 index 35febf1501..0000000000 --- a/src/elements/content-uploader/__tests__/ItemAction.test.js +++ /dev/null @@ -1,89 +0,0 @@ -import * as React from 'react'; -import noop from 'lodash/noop'; -import { shallow } from 'enzyme'; -import PlainButton from '../../../components/plain-button'; -import { ItemActionForTesting as ItemAction } from '../ItemAction'; -import { - ERROR_CODE_UPLOAD_FILE_SIZE_LIMIT_EXCEEDED, - STATUS_PENDING, - STATUS_IN_PROGRESS, - STATUS_COMPLETE, - STATUS_STAGED, - STATUS_ERROR, -} from '../../../constants'; - -describe('elements/content-uploader/ItemAction', () => { - const getWrapper = props => - shallow( - data.defaultMessage }} - onClick={noop} - status={STATUS_PENDING} - {...props} - />, - ); - - test.each` - status - ${STATUS_COMPLETE} - ${STATUS_IN_PROGRESS} - ${STATUS_STAGED} - ${STATUS_ERROR} - ${STATUS_PENDING} - `('should render correctly with $status', ({ status }) => { - const wrapper = shallow( - }} onClick={noop} status={status} />, - ); - - expect(wrapper).toMatchSnapshot(); - }); - - test.each` - status - ${STATUS_COMPLETE} - ${STATUS_IN_PROGRESS} - ${STATUS_STAGED} - ${STATUS_ERROR} - ${STATUS_PENDING} - `('should render correctly with $status and resumable uploads enabled', ({ status }) => { - const wrapper = shallow( - }} - onClick={noop} - status={status} - isResumableUploadsEnabled - />, - ); - - expect(wrapper).toMatchSnapshot(); - }); - - test('should render correctly with STATUS_ERROR and item is folder', () => { - const wrapper = getWrapper({ - status: STATUS_ERROR, - isFolder: true, - }); - - expect(wrapper).toMatchSnapshot(); - }); - - test('should render PrimaryButton with STATUS_ERROR and upload file size exceeded', () => { - const wrapper = getWrapper({ - status: STATUS_ERROR, - error: { code: ERROR_CODE_UPLOAD_FILE_SIZE_LIMIT_EXCEEDED }, - onUpgradeCTAClick: () => {}, - }); - - expect(wrapper.exists('PrimaryButton')).toBe(true); - expect(wrapper.exists('PlainButton')).toBe(false); - }); - - test('should have aria-label "Cancel this upload" when status is pending', () => { - const wrapper = getWrapper({ - status: STATUS_PENDING, - }); - const plainButton = wrapper.find(PlainButton); - expect(plainButton.prop('aria-label')).toBe('Cancel this upload'); - expect(plainButton.prop('aria-describedby')).toBeFalsy(); - }); -}); diff --git a/src/elements/content-uploader/__tests__/ItemAction.test.tsx b/src/elements/content-uploader/__tests__/ItemAction.test.tsx new file mode 100644 index 0000000000..ab2e728bdc --- /dev/null +++ b/src/elements/content-uploader/__tests__/ItemAction.test.tsx @@ -0,0 +1,106 @@ +import * as React from 'react'; +import { AxiosError } from 'axios'; +import { render, screen } from '../../../test-utils/testing-library'; +import ItemAction, { ItemActionProps } from '../ItemAction'; +import { + ERROR_CODE_UPLOAD_FILE_SIZE_LIMIT_EXCEEDED, + STATUS_PENDING, + STATUS_IN_PROGRESS, + STATUS_COMPLETE, + STATUS_STAGED, + STATUS_ERROR, +} from '../../../constants'; + +describe('elements/content-uploader/ItemAction', () => { + const defaultError: AxiosError = { + code: '', + config: undefined, + isAxiosError: false, + toJSON: jest.fn(), + name: '', + message: '', + }; + + const defaultProps: ItemActionProps = { + isResumableUploadsEnabled: false, + onClick: jest.fn(), + status: STATUS_PENDING, + error: defaultError, + isFolder: false, + onUpgradeCTAClick: jest.fn(), + }; + + const getWrapper = (props: Partial) => render(); + + test.each` + status + ${STATUS_COMPLETE} + ${STATUS_IN_PROGRESS} + ${STATUS_STAGED} + ${STATUS_ERROR} + ${STATUS_PENDING} + `('should render correctly with $status', ({ status }: Pick) => { + getWrapper({ status }); + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + + test.each` + status | label + ${STATUS_COMPLETE} | ${'complete'} + ${STATUS_ERROR} | ${'error'} + `( + 'should render correctly with $status and resumable uploads enabled', + ({ status, label }: Pick & { label: string }) => { + getWrapper({ status, isResumableUploadsEnabled: true }); + expect(screen.getByRole('img', { name: label })).toBeInTheDocument(); + }, + ); + + test.each` + status + ${STATUS_IN_PROGRESS} + ${STATUS_PENDING} + ${STATUS_STAGED} + `( + 'should render correctly with $status and resumable uploads enabled', + ({ status }: Pick) => { + getWrapper({ status, isResumableUploadsEnabled: true }); + expect(screen.getByRole('status', { name: 'loading' })).toBeInTheDocument(); + }, + ); + + test.each` + status | label + ${STATUS_IN_PROGRESS} | ${'staged'} + ${STATUS_STAGED} | ${'staged'} + `( + 'should render correctly with $status and resumable uploads disabled', + ({ status, label }: Pick & { label: string }) => { + getWrapper({ status, isResumableUploadsEnabled: false }); + expect(screen.getByRole('img', { name: label })).toBeInTheDocument(); + }, + ); + + test('should render correctly with STATUS_PENDING and resumable uploads disabled', () => { + getWrapper({ status: STATUS_PENDING, isResumableUploadsEnabled: false }); + expect(screen.getByRole('button', { name: 'Cancel this upload' })).toBeInTheDocument(); + }); + + test('should render correctly with STATUS_ERROR and item is folder', () => { + getWrapper({ status: STATUS_ERROR, isFolder: true }); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + test('should render CTA button to upgrade when upload file size exceeded error is received', () => { + getWrapper({ + status: STATUS_ERROR, + error: { ...defaultError, code: ERROR_CODE_UPLOAD_FILE_SIZE_LIMIT_EXCEEDED }, + onUpgradeCTAClick: jest.fn(), + }); + expect( + screen.getByRole('button', { + name: 'Upgrade', + }), + ).toBeInTheDocument(); + }); +}); diff --git a/src/elements/content-uploader/__tests__/ItemList.test.js b/src/elements/content-uploader/__tests__/ItemList.test.js index f42720b8e5..67a39b8ebf 100644 --- a/src/elements/content-uploader/__tests__/ItemList.test.js +++ b/src/elements/content-uploader/__tests__/ItemList.test.js @@ -1,13 +1,29 @@ import * as React from 'react'; import { mount } from 'enzyme'; +// TODO Providers can be removed when converted to RTL - providers are included in the testing utility library +import { IntlProvider } from 'react-intl'; +import { TooltipProvider } from '@box/blueprint-web'; -import { ERROR_CODE_UPLOAD_FILE_SIZE_LIMIT_EXCEEDED, STATUS_COMPLETE, STATUS_ERROR } from '../../../constants'; import ItemList from '../ItemList'; +import { ERROR_CODE_UPLOAD_FILE_SIZE_LIMIT_EXCEEDED, STATUS_COMPLETE, STATUS_ERROR } from '../../../constants'; -jest.mock('@box/react-virtualized/dist/es/AutoSizer', () => ({ children }) => children({ height: 600, width: 600 })); +jest.unmock('react-intl'); // TODO can be removed when converted to RTL +jest.mock( + '@box/react-virtualized/dist/es/AutoSizer', + () => + ({ children }) => + children({ height: 600, width: 600 }), +); describe('elements/content-uploader/ItemList', () => { - const renderComponent = props => mount( {}} {...props} />); + const renderComponent = props => + mount( + + + {}} {...props} /> + + , + ); describe('render()', () => { test('should render default component', () => { @@ -25,11 +41,8 @@ describe('elements/content-uploader/ItemList', () => { ]; const wrapper = renderComponent({ items }); expect(wrapper.find('div.bcu-item-row').length).toBe(3); - const actionColumnStyle = wrapper - .find('.bcu-item-list-action-column') - .first() - .prop('style'); - expect(actionColumnStyle.flex).toEqual('0 0 25px'); + const actionColumnStyle = wrapper.find('.bcu-item-list-action-column').first().prop('style'); + expect(actionColumnStyle.flex).toEqual('0 0 32px'); }); test('should render action column with correct width for upgrade cta', () => { diff --git a/src/elements/content-uploader/__tests__/__snapshots__/ItemAction.test.js.snap b/src/elements/content-uploader/__tests__/__snapshots__/ItemAction.test.js.snap deleted file mode 100644 index bcc6929483..0000000000 --- a/src/elements/content-uploader/__tests__/__snapshots__/ItemAction.test.js.snap +++ /dev/null @@ -1,249 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`elements/content-uploader/ItemAction should render correctly with STATUS_ERROR and item is folder 1`] = `""`; - -exports[`elements/content-uploader/ItemAction should render correctly with complete 1`] = ` -
- - } - theme="default" - > - - } - isDisabled={false} - onClick={[Function]} - type="button" - > - - - -
-`; - -exports[`elements/content-uploader/ItemAction should render correctly with complete and resumable uploads enabled 1`] = ` -
- -
-`; - -exports[`elements/content-uploader/ItemAction should render correctly with error 1`] = ` -
- - } - theme="default" - > - - } - isDisabled={false} - onClick={[Function]} - type="button" - > - - - -
-`; - -exports[`elements/content-uploader/ItemAction should render correctly with error and resumable uploads enabled 1`] = ` -
- - } - theme="default" - > - - } - isDisabled={false} - onClick={[Function]} - type="button" - > - - - -
-`; - -exports[`elements/content-uploader/ItemAction should render correctly with inprogress 1`] = ` -
- - } - theme="default" - > - - } - isDisabled={false} - onClick={[Function]} - type="button" - > - - - -
-`; - -exports[`elements/content-uploader/ItemAction should render correctly with inprogress and resumable uploads enabled 1`] = ` -
- -
-`; - -exports[`elements/content-uploader/ItemAction should render correctly with pending 1`] = ` -
- - } - theme="default" - > - - } - isDisabled={false} - onClick={[Function]} - type="button" - > - - - -
-`; - -exports[`elements/content-uploader/ItemAction should render correctly with pending and resumable uploads enabled 1`] = ` -
- -
-`; - -exports[`elements/content-uploader/ItemAction should render correctly with staged 1`] = ` -
- - } - theme="default" - > - - } - isDisabled={true} - onClick={[Function]} - type="button" - > - - - -
-`; - -exports[`elements/content-uploader/ItemAction should render correctly with staged and resumable uploads enabled 1`] = ` -
- -
-`; diff --git a/src/test-utils/testing-library.tsx b/src/test-utils/testing-library.tsx index 1562e2e828..deb919f446 100644 --- a/src/test-utils/testing-library.tsx +++ b/src/test-utils/testing-library.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { render as rtlRender } from '@testing-library/react'; import { IntlProvider } from 'react-intl'; +import { TooltipProvider } from '@box/blueprint-web'; // Unmock translation framework to allow content-based DOM traversal (in default locale) // Functional stub for lib/intl format functions (works, but only in default locale) @@ -9,7 +10,11 @@ jest.unmock('react-intl'); function renderConnected(ui, { locale = 'en', ...renderOptions } = {}) { // eslint-disable-next-line react/prop-types function Wrapper({ children }) { - return {children}; + return ( + + {children} + + ); } return rtlRender(ui, { wrapper: Wrapper, ...renderOptions }); }