Skip to content

Commit

Permalink
chore(content-uploader): migrate upload state (#3624)
Browse files Browse the repository at this point in the history
* chore(content-uploader): migrate upload state

* chore(content-uploader): add aria-label to svgs

* chore(content-uploader): update test

* chore(content-uploader): resolve nits
  • Loading branch information
tjiang-box authored Sep 4, 2024
1 parent 5ab0f1a commit 42cc092
Show file tree
Hide file tree
Showing 12 changed files with 258 additions and 297 deletions.
6 changes: 6 additions & 0 deletions i18n/en-US.properties
Original file line number Diff line number Diff line change
Expand Up @@ -778,12 +778,16 @@ be.upload = Upload
be.uploadEmptyFileInput = Browse your device
# Message shown for upload link for uploading more folders when there are no items to upload
be.uploadEmptyFolderInput = Select Folders
# Label for upload empty state.
be.uploadEmptyState = Empty state
# Message shown when there are no items to upload and folder upload is disabled
be.uploadEmptyWithFolderUploadDisabled = Drag and drop files
# Message shown when there are no items to upload and folder upload is enabled
be.uploadEmptyWithFolderUploadEnabled = Drag and drop files and folders
# Message shown when there is a network error when uploading
be.uploadError = A network error has occurred while trying to upload.
# Label for upload error state.
be.uploadErrorState = Error state
# Message shown when too many files are uploaded at once
be.uploadErrorTooManyFiles = You can only upload up to {fileLimit} file(s) at a time.
# Message shown when user drag and drops files onto uploads in progress
Expand All @@ -798,6 +802,8 @@ be.uploadSuccess = Success! Your files have been uploaded.
be.uploadSuccessFileInput = Select More Files
# Message shown for upload link for uploading more folders after a successful upload
be.uploadSuccessFolderInput = Select More Folders
# Label for upload success state.
be.uploadSuccessState = Success state
# Cancel upload button tooltip
be.uploadsCancelButtonTooltip = Cancel this upload
# Default error message shown when upload fails
Expand Down
15 changes: 15 additions & 0 deletions src/elements/common/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,21 @@ const messages = defineMessages({
description: 'Label for upload action.',
defaultMessage: 'Upload',
},
uploadEmptyState: {
id: 'be.uploadEmptyState',
description: 'Label for upload empty state.',
defaultMessage: 'Empty state',
},
uploadErrorState: {
id: 'be.uploadErrorState',
description: 'Label for upload error state.',
defaultMessage: 'Error state',
},
uploadSuccessState: {
id: 'be.uploadSuccessState',
description: 'Label for upload success state.',
defaultMessage: 'Success state',
},
add: {
id: 'be.add',
description: 'Label for add action',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,17 @@
/**
* @flow
* @file Upload state component
*/

import * as React from 'react';
import classNames from 'classnames';
import { FormattedMessage } from 'react-intl';
import ErrorEmptyState from '../../icons/states/ErrorEmptyState';
import { useIntl, FormattedMessage } from 'react-intl';
import { HatWand } from '@box/blueprint-web-assets/illustrations/Medium';

import UploadEmptyState from '../../icons/states/UploadEmptyState';
import UploadSuccessState from '../../icons/states/UploadSuccessState';
import messages from '../common/messages';
import UploadStateContent from './UploadStateContent';
import { VIEW_ERROR, VIEW_UPLOAD_EMPTY, VIEW_UPLOAD_IN_PROGRESS, VIEW_UPLOAD_SUCCESS } from '../../constants';
import type { View } from '../../common/types/core';

import { VIEW_ERROR, VIEW_UPLOAD_EMPTY, VIEW_UPLOAD_IN_PROGRESS, VIEW_UPLOAD_SUCCESS } from '../../constants';

import messages from '../common/messages';

import './UploadState.scss';

type Props = {
Expand All @@ -22,20 +20,29 @@ type Props = {
isFolderUploadEnabled: boolean,
isOver: boolean,
isTouch: boolean,
onSelect: Function,
view: View,
onSelect: () => void,
view: View
};

const UploadState = ({ canDrop, hasItems, isOver, isTouch, view, onSelect, isFolderUploadEnabled }: Props) => {
const UploadState = ({
canDrop,
hasItems,
isOver,
isTouch,
view,
onSelect,
isFolderUploadEnabled,
}: Props) => {
const intl = useIntl();
let icon;
let content;
switch (view) {
case VIEW_ERROR:
icon = <ErrorEmptyState />;
icon = <HatWand aria-label={intl.formatMessage(messages.uploadErrorState)} height={126} width={130} />;
content = <UploadStateContent message={<FormattedMessage {...messages.uploadError} />} />;
break;
case VIEW_UPLOAD_EMPTY:
icon = <UploadEmptyState />;
icon = <UploadEmptyState title={<FormattedMessage {...messages.uploadEmptyState} />} />;
/* eslint-disable no-nested-ternary */
content =
canDrop && hasItems ? (
Expand Down Expand Up @@ -65,11 +72,11 @@ const UploadState = ({ canDrop, hasItems, isOver, isTouch, view, onSelect, isFol
/* eslint-enable no-nested-ternary */
break;
case VIEW_UPLOAD_IN_PROGRESS:
icon = <UploadEmptyState />;
icon = <UploadEmptyState title={<FormattedMessage {...messages.uploadEmptyState} />} />;
content = <UploadStateContent message={<FormattedMessage {...messages.uploadInProgress} />} />;
break;
case VIEW_UPLOAD_SUCCESS:
icon = <UploadSuccessState />;
icon = <UploadSuccessState title={<FormattedMessage {...messages.uploadSuccessState} />} />;
content = (
<UploadStateContent
fileInputLabel={<FormattedMessage {...messages.uploadSuccessFileInput} />}
Expand Down
107 changes: 107 additions & 0 deletions src/elements/content-uploader/UploadState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import * as React from 'react';
import classNames from 'classnames';
import { useIntl } from 'react-intl';
import { HatWand } from '@box/blueprint-web-assets/illustrations/Medium';

import UploadEmptyState from '../../icons/states/UploadEmptyState';
import UploadSuccessState from '../../icons/states/UploadSuccessState';
import UploadStateContent from './UploadStateContent';
import type { View } from '../../common/types/core';

import { VIEW_ERROR, VIEW_UPLOAD_EMPTY, VIEW_UPLOAD_IN_PROGRESS, VIEW_UPLOAD_SUCCESS } from '../../constants';

import messages from '../common/messages';

import './UploadState.scss';

export interface UploadStateProps {
canDrop: boolean;
hasItems: boolean;
isFolderUploadEnabled: boolean;
isOver: boolean;
isTouch: boolean;
onSelect: () => void;
view: View;
}

const UploadState = ({
canDrop,
hasItems,
isOver,
isTouch,
view,
onSelect,
isFolderUploadEnabled,
}: UploadStateProps) => {
const { formatMessage } = useIntl();
let icon;
let content;
switch (view) {
case VIEW_ERROR:
icon = <HatWand aria-label={formatMessage(messages.uploadErrorState)} height={126} width={130} />;
content = <UploadStateContent message={formatMessage(messages.uploadError)} />;
break;
case VIEW_UPLOAD_EMPTY:
icon = <UploadEmptyState title={formatMessage(messages.uploadEmptyState)} />;
/* eslint-disable no-nested-ternary */
content =
canDrop && hasItems ? (
<UploadStateContent message={formatMessage(messages.uploadInProgress)} />
) : isTouch ? (
<UploadStateContent
fileInputLabel={formatMessage(messages.uploadNoDragDrop)}
onChange={onSelect}
useButton
/>
) : (
<UploadStateContent
fileInputLabel={formatMessage(messages.uploadEmptyFileInput)}
folderInputLabel={isFolderUploadEnabled && formatMessage(messages.uploadEmptyFolderInput)}
message={
isFolderUploadEnabled
? formatMessage(messages.uploadEmptyWithFolderUploadEnabled)
: formatMessage(messages.uploadEmptyWithFolderUploadDisabled)
}
onChange={onSelect}
/>
);
/* eslint-enable no-nested-ternary */
break;
case VIEW_UPLOAD_IN_PROGRESS:
icon = <UploadEmptyState title={formatMessage(messages.uploadEmptyState)} />;
content = <UploadStateContent message={formatMessage(messages.uploadInProgress)} />;
break;
case VIEW_UPLOAD_SUCCESS:
icon = <UploadSuccessState title={formatMessage(messages.uploadSuccessState)} />;
content = (
<UploadStateContent
fileInputLabel={formatMessage(messages.uploadSuccessFileInput)}
folderInputLabel={isFolderUploadEnabled && formatMessage(messages.uploadSuccessFolderInput)}
message={formatMessage(messages.uploadSuccess)}
onChange={onSelect}
useButton={isTouch}
/>
);
break;
default:
break;
}

const className = classNames('bcu-upload-state', {
'bcu-is-droppable': isOver && canDrop,
'bcu-is-not-droppable': isOver && !canDrop,
'bcu-has-items': hasItems,
});

return (
<div className={className}>
<div>
{icon}
{content}
</div>
<div className="bcu-drag-drop-overlay" />
</div>
);
};

export default UploadState;
16 changes: 8 additions & 8 deletions src/elements/content-uploader/__tests__/ItemAction.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ describe('elements/content-uploader/ItemAction', () => {
onUpgradeCTAClick: jest.fn(),
};

const getWrapper = (props: Partial<ItemActionProps>) => render(<ItemAction {...defaultProps} {...props} />);
const renderComponent = (props: Partial<ItemActionProps>) => render(<ItemAction {...defaultProps} {...props} />);

test.each`
status
Expand All @@ -40,7 +40,7 @@ describe('elements/content-uploader/ItemAction', () => {
${STATUS_ERROR}
${STATUS_PENDING}
`('should render correctly with $status', ({ status }: Pick<ItemActionProps, 'status'>) => {
getWrapper({ status });
renderComponent({ status });
expect(screen.getByRole('button')).toBeInTheDocument();
});

Expand All @@ -51,7 +51,7 @@ describe('elements/content-uploader/ItemAction', () => {
`(
'should render correctly with $status and resumable uploads enabled',
({ status, label }: Pick<ItemActionProps, 'status'> & { label: string }) => {
getWrapper({ status, isResumableUploadsEnabled: true });
renderComponent({ status, isResumableUploadsEnabled: true });
expect(screen.getByRole('img', { name: label })).toBeInTheDocument();
},
);
Expand All @@ -64,7 +64,7 @@ describe('elements/content-uploader/ItemAction', () => {
`(
'should render correctly with $status and resumable uploads enabled',
({ status }: Pick<ItemActionProps, 'status'>) => {
getWrapper({ status, isResumableUploadsEnabled: true });
renderComponent({ status, isResumableUploadsEnabled: true });
expect(screen.getByRole('status', { name: 'loading' })).toBeInTheDocument();
},
);
Expand All @@ -76,23 +76,23 @@ describe('elements/content-uploader/ItemAction', () => {
`(
'should render correctly with $status and resumable uploads disabled',
({ status, label }: Pick<ItemActionProps, 'status'> & { label: string }) => {
getWrapper({ status, isResumableUploadsEnabled: false });
renderComponent({ 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 });
renderComponent({ 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 });
renderComponent({ 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({
renderComponent({
status: STATUS_ERROR,
error: { ...defaultError, code: ERROR_CODE_UPLOAD_FILE_SIZE_LIMIT_EXCEEDED },
onUpgradeCTAClick: jest.fn(),
Expand Down
8 changes: 4 additions & 4 deletions src/elements/content-uploader/__tests__/ItemRemove.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,25 @@ import ItemRemove, { ItemRemoveProps } from '../ItemRemove';
import { STATUS_IN_PROGRESS, STATUS_STAGED } from '../../../constants';

describe('elements/content-uploader/ItemRemove', () => {
const getWrapper = (props: Partial<ItemRemoveProps>) =>
const renderComponent = (props: Partial<ItemRemoveProps>) =>
render(<ItemRemove onClick={jest.fn()} status={STATUS_IN_PROGRESS} {...props} />);

test('should have aria-label "Remove" and no aria-describedby', () => {
getWrapper({});
renderComponent({});
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-label', 'Remove');
expect(button).not.toHaveAttribute('aria-describedby');
});

test('should render disabled button when status is STATUS_STAGED', () => {
getWrapper({ status: STATUS_STAGED });
renderComponent({ status: STATUS_STAGED });
const button = screen.getByRole('button');
expect(button).toBeDisabled();
});

test('should call onClick when button is clicked', () => {
const mockOnClick = jest.fn();
getWrapper({ onClick: mockOnClick });
renderComponent({ onClick: mockOnClick });

fireEvent.click(screen.getByRole('button'));
expect(mockOnClick).toHaveBeenCalledTimes(1);
Expand Down
Loading

0 comments on commit 42cc092

Please sign in to comment.