diff --git a/src/elements/content-uploader/ContentUploader.tsx b/src/elements/content-uploader/ContentUploader.tsx index 928bd3ae12..04af3e3720 100644 --- a/src/elements/content-uploader/ContentUploader.tsx +++ b/src/elements/content-uploader/ContentUploader.tsx @@ -14,6 +14,7 @@ import DroppableContent from './DroppableContent'; import Footer from './Footer'; import UploadsManager from './UploadsManager'; import { getUploadItemKey, mapToModernizedUploadItems } from './utils/mapToModernizedUploadItem'; +import './ModernizedUploadsManagerPanel.scss'; import API from '../../api'; import Browser from '../../utils/Browser'; import Internationalize from '../common/Internationalize'; @@ -116,18 +117,22 @@ export interface ContentUploaderProps { onToggle?: (isExpanded: boolean) => void; } +type ModernizedPanelState = 'hidden' | 'shown' | 'dismissing'; + type State = { errorCode?: string; isCancelAllModalOpen: boolean; isUploadsManagerExpanded: boolean; itemIds: Object; items: UploadItem[]; + modernizedPanelState: ModernizedPanelState; view: View; }; const CHUNKED_UPLOAD_MIN_SIZE_BYTES = 104857600; // 100MB const FILE_LIMIT_DEFAULT = 100; // Upload at most 100 files at once by default const HIDE_UPLOAD_MANAGER_DELAY_MS_DEFAULT = 8000; +const SLIDE_OUT_ANIMATION_NAME = 'bcu-modernized-slideOut'; const EXPAND_UPLOADS_MANAGER_ITEMS_NUM_THRESHOLD = 5; const UPLOAD_CONCURRENCY = 6; @@ -146,6 +151,12 @@ class ContentUploader extends Component { isAutoExpanded: boolean = false; + modernizedDismissTimer: ReturnType | null = null; + + isPanelHovered: boolean = false; + + isPanelFocused: boolean = false; + itemsRef: React.MutableRefObject; itemIdsRef: React.MutableRefObject; @@ -199,6 +210,7 @@ class ContentUploader extends Component { itemIds: {}, isCancelAllModalOpen: false, isUploadsManagerExpanded: false, + modernizedPanelState: 'hidden', }; this.id = uniqueid('bcu_'); @@ -235,6 +247,7 @@ class ContentUploader extends Component { */ componentWillUnmount() { this.cancel(); + this.clearModernizedDismissTimer(); } /** @@ -243,18 +256,42 @@ class ContentUploader extends Component { * @return {void} */ componentDidUpdate() { - const { files, dataTransferItems, useUploadsManager } = this.props; + const { files, dataTransferItems, useUploadsManager, enableModernizedUploads } = this.props; + const { items, modernizedPanelState } = this.state; const hasFiles = Array.isArray(files) && files.length > 0; const hasItems = Array.isArray(dataTransferItems) && dataTransferItems.length > 0; const hasUploads = hasFiles || hasItems; - if (!useUploadsManager || !hasUploads) { + if (useUploadsManager && hasUploads) { + // TODO: this gets called unnecessarily (upon each render regardless of the queue not changing) + this.addFilesWithOptionsToUploadQueueAndStartUpload(files, dataTransferItems); + } + + if (!enableModernizedUploads) { return; } - // TODO: this gets called unnecessarily (upon each render regardless of the queue not changing) - this.addFilesWithOptionsToUploadQueueAndStartUpload(files, dataTransferItems); + const hasItemsInUploadQueue = items.length > 0; + const isUploadsBatchSuccessfullyComplete = items.every( + item => item.status === STATUS_COMPLETE || item.status === STATUS_CANCELED, + ); + + if (hasItemsInUploadQueue && modernizedPanelState === 'hidden') { + this.showModernizedPanel(); + } + + if (!isUploadsBatchSuccessfullyComplete) { + this.clearModernizedDismissTimer(); + + if (modernizedPanelState === 'dismissing') { + this.showModernizedPanel(); + } + } else if (modernizedPanelState === 'shown') { + if (this.modernizedDismissTimer === null) { + this.startModernizedDismissTimer(); + } + } } /** @@ -1360,6 +1397,11 @@ class ContentUploader extends Component { * @return {void} */ checkClearUploadItems = () => { + const { enableModernizedUploads } = this.props; + if (enableModernizedUploads) { + return; + } + this.resetItemsTimeout = setTimeout( this.resetUploadsManagerItemsWhenUploadsComplete, HIDE_UPLOAD_MANAGER_DELAY_MS_DEFAULT, @@ -1435,13 +1477,16 @@ class ContentUploader extends Component { * @return {void} */ resetUploadsManagerItemsWhenUploadsComplete = (): void => { - const { onCancel, useUploadsManager } = this.props; + const { onCancel, useUploadsManager, enableModernizedUploads } = this.props; const { view } = this.state; const isUploadsManagerExpanded = this.getIsExpanded(); // Do not reset items when upload manger is expanded or there're uploads in progress if ( - (isUploadsManagerExpanded && useUploadsManager && !!this.itemsRef.current.length) || + (isUploadsManagerExpanded && + useUploadsManager && + !!this.itemsRef.current.length && + !enableModernizedUploads) || view === VIEW_UPLOAD_IN_PROGRESS ) { return; @@ -1458,6 +1503,92 @@ class ContentUploader extends Component { }); }; + clearModernizedDismissTimer = (): void => { + if (this.modernizedDismissTimer !== null) { + clearTimeout(this.modernizedDismissTimer); + this.modernizedDismissTimer = null; + } + }; + + showModernizedPanel = (): void => { + this.setState({ modernizedPanelState: 'shown' }); + }; + + startModernizedDismissTimer = (): void => { + this.clearModernizedDismissTimer(); + + const { modernizedPanelState, items } = this.state; + const isUploadsBatchSuccessfullyComplete = items.every( + item => item.status === STATUS_COMPLETE || item.status === STATUS_CANCELED, + ); + + if ( + !isUploadsBatchSuccessfullyComplete || + modernizedPanelState !== 'shown' || + this.isPanelHovered || + this.isPanelFocused + ) { + return; + } + + this.modernizedDismissTimer = setTimeout(() => { + this.setState({ modernizedPanelState: 'dismissing' }); + this.modernizedDismissTimer = null; + }, HIDE_UPLOAD_MANAGER_DELAY_MS_DEFAULT); + }; + + finalizeModernizedDismiss = (): void => { + this.clearModernizedDismissTimer(); + this.resetUploadsManagerItemsWhenUploadsComplete(); + this.setState({ modernizedPanelState: 'hidden' }); + }; + + handleModernizedMouseEnter = (): void => { + this.isPanelHovered = true; + this.clearModernizedDismissTimer(); + + if (this.state.modernizedPanelState === 'dismissing') { + this.showModernizedPanel(); + } + }; + + handleModernizedMouseLeave = (): void => { + this.isPanelHovered = false; + this.startModernizedDismissTimer(); + }; + + handleModernizedFocus = (event): void => { + // Ensures that we don't block uploads closing timer because of implicit focus (e.g. on button click) + if (!(event.target as HTMLElement).matches(':focus-visible')) { + return; + } + this.isPanelFocused = true; + this.clearModernizedDismissTimer(); + + if (this.state.modernizedPanelState === 'dismissing') { + this.showModernizedPanel(); + } + }; + + handleModernizedBlur = (event: React.FocusEvent): void => { + const relatedTarget = event.relatedTarget as Node | null; + + if (relatedTarget && event.currentTarget.contains(relatedTarget)) { + return; + } + + this.isPanelFocused = false; + this.startModernizedDismissTimer(); + }; + + handleModernizedAnimationEnd = (event: React.AnimationEvent): void => { + if (event.animationName !== SLIDE_OUT_ANIMATION_NAME) { + return; + } + + this.finalizeModernizedDismiss(); + }; + /** * Adds file to the upload queue and starts upload immediately * @@ -1499,7 +1630,7 @@ class ContentUploader extends Component { theme, useUploadsManager, }: ContentUploaderProps = this.props; - const { view, items, errorCode, isCancelAllModalOpen }: State = this.state; + const { view, items, errorCode, isCancelAllModalOpen, modernizedPanelState }: State = this.state; const isUploadsManagerExpanded = this.getIsExpanded(); const isEmpty = items.length === 0; const isVisible = !isEmpty || !!isDraggingItemsToUploadsManager; @@ -1518,23 +1649,37 @@ class ContentUploader extends Component { return (
- - + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} +
+ + +
); } diff --git a/src/elements/content-uploader/ModernizedUploadsManagerPanel.scss b/src/elements/content-uploader/ModernizedUploadsManagerPanel.scss new file mode 100644 index 0000000000..50e73f2eb5 --- /dev/null +++ b/src/elements/content-uploader/ModernizedUploadsManagerPanel.scss @@ -0,0 +1,47 @@ +@keyframes bcu-modernized-slideIn { + from { + opacity: 0; + transform: translateY(20px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes bcu-modernized-slideOut { + from { + opacity: 1; + transform: translateY(0); + } + + to { + opacity: 0; + transform: translateY(20px); + } +} + +.bcu-modernized-panel { + &.visible { + animation: bcu-modernized-slideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) both; + } + + &.dismissing { + animation: bcu-modernized-slideOut 0.2s cubic-bezier(0.4, 0, 1, 1) both; + } + + &.hidden { + display: none; + } + + @media (prefers-reduced-motion: reduce) { + &.visible { + animation: none; + } + + &.dismissing { + animation: bcu-modernized-slideOut 0.001ms both; + } + } +} diff --git a/src/elements/content-uploader/__tests__/ContentUploader.test.js b/src/elements/content-uploader/__tests__/ContentUploader.test.js index 96be39a4ae..18277aab46 100644 --- a/src/elements/content-uploader/__tests__/ContentUploader.test.js +++ b/src/elements/content-uploader/__tests__/ContentUploader.test.js @@ -1441,4 +1441,260 @@ describe('elements/content-uploader/ContentUploader', () => { }); }); }); + + describe('modernized panel open/close lifecycle', () => { + const HIDE_DELAY_MS = 8000; + + const makeItem = (name, status) => { + let progress = 0; + if (status === STATUS_COMPLETE) { + progress = 100; + } else if (status === STATUS_IN_PROGRESS) { + progress = 50; + } + return { name, extension: 'pdf', progress, status, file: { name } }; + }; + + const armDismissTimer = wrapper => { + const instance = wrapper.instance(); + wrapper.setState({ + modernizedPanelState: 'shown', + items: [makeItem('a.pdf', STATUS_IN_PROGRESS)], + }); + wrapper.setState({ + items: [makeItem('a.pdf', STATUS_COMPLETE)], + }); + return instance; + }; + + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('starts dismiss timer when batch transitions to all-complete and panel is shown', () => { + const setTimeoutSpy = jest.spyOn(global, 'setTimeout'); + const wrapper = getWrapper({ enableModernizedUploads: true }); + const instance = armDismissTimer(wrapper); + + expect(instance.modernizedDismissTimer).not.toBeNull(); + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), HIDE_DELAY_MS); + setTimeoutSpy.mockRestore(); + }); + + test('transitions to dismissing only after the full delay', () => { + const wrapper = getWrapper({ enableModernizedUploads: true }); + armDismissTimer(wrapper); + + jest.advanceTimersByTime(HIDE_DELAY_MS - 1); + expect(wrapper.state('modernizedPanelState')).toBe('shown'); + + jest.advanceTimersByTime(1); + expect(wrapper.state('modernizedPanelState')).toBe('dismissing'); + }); + + test('clears timer when a new in-progress item is added mid-wait', () => { + const wrapper = getWrapper({ enableModernizedUploads: true }); + const instance = armDismissTimer(wrapper); + + jest.advanceTimersByTime(4000); + + wrapper.setState({ + items: [makeItem('a.pdf', STATUS_COMPLETE), makeItem('b.pdf', STATUS_IN_PROGRESS)], + }); + + expect(instance.modernizedDismissTimer).toBeNull(); + + jest.advanceTimersByTime(HIDE_DELAY_MS); + expect(wrapper.state('modernizedPanelState')).toBe('shown'); + }); + + test('clears timer when an item flips to STATUS_ERROR mid-wait', () => { + const wrapper = getWrapper({ enableModernizedUploads: true }); + const instance = armDismissTimer(wrapper); + + jest.advanceTimersByTime(4000); + + wrapper.setState({ + items: [makeItem('a.pdf', STATUS_COMPLETE), makeItem('b.pdf', STATUS_ERROR)], + }); + + expect(instance.modernizedDismissTimer).toBeNull(); + expect(wrapper.state('modernizedPanelState')).toBe('shown'); + }); + + test('hover cancels the timer and mouse-leave re-arms it', () => { + const wrapper = getWrapper({ enableModernizedUploads: true }); + const instance = armDismissTimer(wrapper); + + instance.handleModernizedMouseEnter(); + + expect(instance.isPanelHovered).toBe(true); + expect(instance.modernizedDismissTimer).toBeNull(); + + instance.handleModernizedMouseLeave(); + + expect(instance.isPanelHovered).toBe(false); + expect(instance.modernizedDismissTimer).not.toBeNull(); + + jest.advanceTimersByTime(HIDE_DELAY_MS); + expect(wrapper.state('modernizedPanelState')).toBe('dismissing'); + }); + + test('keyboard focus cancels the timer and blur re-arms it', () => { + const wrapper = getWrapper({ enableModernizedUploads: true }); + const instance = armDismissTimer(wrapper); + + instance.handleModernizedFocus({ + target: { matches: () => true }, + }); + + expect(instance.isPanelFocused).toBe(true); + expect(instance.modernizedDismissTimer).toBeNull(); + + instance.handleModernizedBlur({ + relatedTarget: null, + currentTarget: { contains: () => false }, + }); + + expect(instance.isPanelFocused).toBe(false); + expect(instance.modernizedDismissTimer).not.toBeNull(); + + jest.advanceTimersByTime(HIDE_DELAY_MS); + expect(wrapper.state('modernizedPanelState')).toBe('dismissing'); + }); + + test('mouse-induced focus does not cancel the timer', () => { + const wrapper = getWrapper({ enableModernizedUploads: true }); + const instance = armDismissTimer(wrapper); + + instance.handleModernizedFocus({ + target: { matches: () => false }, + }); + + expect(instance.isPanelFocused).toBe(false); + expect(instance.modernizedDismissTimer).not.toBeNull(); + }); + + test('blur to a child element does not re-arm the timer', () => { + const wrapper = getWrapper({ enableModernizedUploads: true }); + const instance = armDismissTimer(wrapper); + + instance.handleModernizedFocus({ + target: { matches: () => true }, + }); + expect(instance.modernizedDismissTimer).toBeNull(); + + const childNode = {}; + instance.handleModernizedBlur({ + relatedTarget: childNode, + currentTarget: { contains: target => target === childNode }, + }); + + expect(instance.isPanelFocused).toBe(true); + expect(instance.modernizedDismissTimer).toBeNull(); + }); + + test('handleModernizedAnimationEnd finalizes dismiss only for the slide-out animation', () => { + const wrapper = getWrapper({ enableModernizedUploads: true }); + const instance = wrapper.instance(); + const resetSpy = jest + .spyOn(instance, 'resetUploadsManagerItemsWhenUploadsComplete') + .mockImplementation(() => {}); + + wrapper.setState({ modernizedPanelState: 'dismissing' }); + + instance.handleModernizedAnimationEnd({ animationName: 'something-else' }); + expect(wrapper.state('modernizedPanelState')).toBe('dismissing'); + expect(resetSpy).not.toHaveBeenCalled(); + + instance.handleModernizedAnimationEnd({ animationName: 'bcu-modernized-slideOut' }); + expect(wrapper.state('modernizedPanelState')).toBe('hidden'); + expect(resetSpy).toHaveBeenCalled(); + }); + + test('dismisses only after the latest batch completes when multiple rapid batches occur', () => { + const wrapper = getWrapper({ enableModernizedUploads: true }); + const instance = armDismissTimer(wrapper); + + jest.advanceTimersByTime(3000); + + wrapper.setState({ + items: [ + makeItem('a.pdf', STATUS_COMPLETE), + makeItem('b.pdf', STATUS_COMPLETE), + makeItem('c.pdf', STATUS_IN_PROGRESS), + ], + }); + expect(instance.modernizedDismissTimer).toBeNull(); + + wrapper.setState({ + items: [ + makeItem('a.pdf', STATUS_COMPLETE), + makeItem('b.pdf', STATUS_COMPLETE), + makeItem('c.pdf', STATUS_COMPLETE), + ], + }); + expect(instance.modernizedDismissTimer).not.toBeNull(); + + jest.advanceTimersByTime(3000); + expect(wrapper.state('modernizedPanelState')).toBe('shown'); + + jest.advanceTimersByTime(5000); + expect(wrapper.state('modernizedPanelState')).toBe('dismissing'); + }); + + test('returns panel to shown when a new in-progress item arrives while dismissing', () => { + const wrapper = getWrapper({ enableModernizedUploads: true }); + wrapper.setState({ + modernizedPanelState: 'dismissing', + items: [makeItem('a.pdf', STATUS_COMPLETE)], + }); + + wrapper.setState({ + items: [makeItem('a.pdf', STATUS_COMPLETE), makeItem('b.pdf', STATUS_IN_PROGRESS)], + }); + + expect(wrapper.state('modernizedPanelState')).toBe('shown'); + }); + + test('componentWillUnmount clears the dismiss timer', () => { + const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout'); + const wrapper = getWrapper({ enableModernizedUploads: true }); + const instance = armDismissTimer(wrapper); + const timerId = instance.modernizedDismissTimer; + + wrapper.unmount(); + + expect(clearTimeoutSpy).toHaveBeenCalledWith(timerId); + clearTimeoutSpy.mockRestore(); + }); + + test('shows panel when items appear from hidden state', () => { + const wrapper = getWrapper({ enableModernizedUploads: true }); + + expect(wrapper.state('modernizedPanelState')).toBe('hidden'); + + wrapper.setState({ + items: [makeItem('a.pdf', STATUS_IN_PROGRESS)], + }); + + expect(wrapper.state('modernizedPanelState')).toBe('shown'); + }); + + test('does not change modernized panel state when enableModernizedUploads is false', () => { + const wrapper = getWrapper({ enableModernizedUploads: false }); + const instance = wrapper.instance(); + + wrapper.setState({ + items: [makeItem('a.pdf', STATUS_COMPLETE)], + }); + + expect(wrapper.state('modernizedPanelState')).toBe('hidden'); + expect(instance.modernizedDismissTimer).toBeNull(); + }); + }); }); diff --git a/src/elements/content-uploader/stories/ContentUploader.stories.js b/src/elements/content-uploader/stories/ContentUploader.stories.js index 693d44d0a0..98285e1361 100644 --- a/src/elements/content-uploader/stories/ContentUploader.stories.js +++ b/src/elements/content-uploader/stories/ContentUploader.stories.js @@ -1,6 +1,8 @@ // @flow +import { http, HttpResponse } from 'msw'; import ContentUploader from '../ContentUploader'; import mockTheme from '../../common/__mocks__/mockTheme'; +import { DEFAULT_HOSTNAME_API, DEFAULT_HOSTNAME_UPLOAD } from '../../../constants'; export const basic = {}; @@ -13,6 +15,24 @@ export const withTheming = { export const withModernizedUploads = { args: { enableModernizedUploads: true, + useUploadsManager: true, + files: [ + new File(['contents'], 'upload_file_1.txt', { type: 'text/plain' }), + new File(['contents'], 'upload_file_2.txt', { type: 'text/plain' }), + new File(['contents'], 'upload_file_3.txt', { type: 'text/plain' }), + ], + }, + parameters: { + msw: { + handlers: [ + http.options(`${DEFAULT_HOSTNAME_API}/2.0/files/content`, () => { + return HttpResponse.json({}); + }), + http.post(`${DEFAULT_HOSTNAME_UPLOAD}/api/2.0/files/content`, () => { + return HttpResponse.json({}); + }), + ], + }, }, };