diff --git a/src/elements/content-uploader/ContentUploader.tsx b/src/elements/content-uploader/ContentUploader.tsx index d9f3856da3..928bd3ae12 100644 --- a/src/elements/content-uploader/ContentUploader.tsx +++ b/src/elements/content-uploader/ContentUploader.tsx @@ -112,6 +112,8 @@ export interface ContentUploaderProps { uploadHost: string; useUploadsManager?: boolean; enableModernizedUploads?: boolean; + isExpanded?: boolean; + onToggle?: (isExpanded: boolean) => void; } type State = { @@ -655,7 +657,7 @@ class ContentUploader extends Component { */ addToQueue = (newItems: UploadItem[], itemUpdateCallback: Function) => { const { fileLimit, useUploadsManager } = this.props; - const { isUploadsManagerExpanded } = this.state; + const isUploadsManagerExpanded = this.getIsExpanded(); let updatedItems = []; const prevItemsNum = this.itemsRef.current.length; @@ -965,8 +967,35 @@ class ContentUploader extends Component { }); }; + /** + * Whether the component is in controlled-expand mode. + * + * @return {boolean} + */ + isExpandControlled = (): boolean => this.props.isExpanded !== undefined; + + /** + * Returns the resolved expanded state. When `isExpanded` prop is provided + * the component is in controlled mode and the prop wins; otherwise the + * internal state is used (uncontrolled mode). + * + * @return {boolean} + */ + getIsExpanded = (): boolean => { + if (this.isExpandControlled()) { + return this.props.isExpanded; + } + + return this.state.isUploadsManagerExpanded; + }; + resetUploadManagerExpandState = () => { this.isAutoExpanded = false; + // In controlled mode the consumer owns the expanded state. Auto-collapse + // (post-completion timeout, cancel-all) must not mutate that preference. + if (this.isExpandControlled()) { + return; + } this.setState({ isUploadsManagerExpanded: false, }); @@ -1297,6 +1326,12 @@ class ContentUploader extends Component { clearTimeout(this.resetItemsTimeout); + // In controlled mode the consumer owns the expanded state. Auto-expand + // (file-count threshold) must not mutate that preference. + if (this.isExpandControlled()) { + return; + } + this.setState({ isUploadsManagerExpanded: true }); }; @@ -1337,13 +1372,17 @@ class ContentUploader extends Component { * @return {void} */ toggleUploadsManager = (): void => { - const { isUploadsManagerExpanded } = this.state; + const { onToggle } = this.props; + const isExpanded = this.getIsExpanded(); + const nextExpanded = !isExpanded; - if (isUploadsManagerExpanded) { + if (isExpanded) { this.minimizeUploadsManager(); - } else { + } else if (!this.isExpandControlled()) { this.expandUploadsManager(); } + + onToggle?.(nextExpanded); }; /** @@ -1397,7 +1436,8 @@ class ContentUploader extends Component { */ resetUploadsManagerItemsWhenUploadsComplete = (): void => { const { onCancel, useUploadsManager } = this.props; - const { isUploadsManagerExpanded, view } = this.state; + const { view } = this.state; + const isUploadsManagerExpanded = this.getIsExpanded(); // Do not reset items when upload manger is expanded or there're uploads in progress if ( @@ -1459,7 +1499,8 @@ class ContentUploader extends Component { theme, useUploadsManager, }: ContentUploaderProps = this.props; - const { view, items, errorCode, isCancelAllModalOpen, isUploadsManagerExpanded }: State = this.state; + const { view, items, errorCode, isCancelAllModalOpen }: State = this.state; + const isUploadsManagerExpanded = this.getIsExpanded(); const isEmpty = items.length === 0; const isVisible = !isEmpty || !!isDraggingItemsToUploadsManager; diff --git a/src/elements/content-uploader/__tests__/ContentUploader.test.js b/src/elements/content-uploader/__tests__/ContentUploader.test.js index 0697862b00..96be39a4ae 100644 --- a/src/elements/content-uploader/__tests__/ContentUploader.test.js +++ b/src/elements/content-uploader/__tests__/ContentUploader.test.js @@ -680,6 +680,123 @@ describe('elements/content-uploader/ContentUploader', () => { }); }); + describe('controlled isExpanded / onToggle', () => { + test('uses isExpanded prop value when in controlled mode', () => { + const wrapper = getWrapper({ enableModernizedUploads: true, isExpanded: true, onToggle: jest.fn() }); + expect(wrapper.find(UploadsManagerBP).prop('isExpanded')).toBe(true); + + wrapper.setProps({ isExpanded: false }); + expect(wrapper.find(UploadsManagerBP).prop('isExpanded')).toBe(false); + }); + + test('falls back to internal state when isExpanded prop is not provided', () => { + const wrapper = getWrapper({ enableModernizedUploads: true }); + expect(wrapper.find(UploadsManagerBP).prop('isExpanded')).toBe(false); + + wrapper.setState({ isUploadsManagerExpanded: true }); + expect(wrapper.find(UploadsManagerBP).prop('isExpanded')).toBe(true); + }); + + test('toggleUploadsManager calls onToggle with next value in controlled mode and does not mutate internal state', () => { + const onToggle = jest.fn(); + const wrapper = getWrapper({ + enableModernizedUploads: true, + useUploadsManager: true, + isExpanded: false, + onToggle, + }); + + wrapper.instance().toggleUploadsManager(); + + expect(onToggle).toHaveBeenCalledTimes(1); + expect(onToggle).toHaveBeenCalledWith(true); + expect(wrapper.state().isUploadsManagerExpanded).toBe(false); + }); + + test('toggleUploadsManager flips internal state in uncontrolled mode and fires onToggle', () => { + const onToggle = jest.fn(); + const wrapper = getWrapper({ useUploadsManager: true, onToggle }); + + wrapper.instance().toggleUploadsManager(); + + expect(wrapper.state().isUploadsManagerExpanded).toBe(true); + expect(onToggle).toHaveBeenCalledTimes(1); + expect(onToggle).toHaveBeenCalledWith(true); + }); + + test('toggleUploadsManager does not require onToggle in uncontrolled mode', () => { + const wrapper = getWrapper({ useUploadsManager: true }); + + expect(() => wrapper.instance().toggleUploadsManager()).not.toThrow(); + expect(wrapper.state().isUploadsManagerExpanded).toBe(true); + }); + + test('auto-expand on file-count threshold does not mutate state in controlled mode', () => { + const onToggle = jest.fn(); + const wrapper = getWrapper({ + useUploadsManager: true, + isExpanded: false, + onToggle, + }); + const instance = wrapper.instance(); + const mockAPI = { upload: () => {} }; + instance.createAPIFactory = jest.fn().mockReturnValue({ + getPlainUploadAPI: () => mockAPI, + getChunkedUploadAPI: () => mockAPI, + }); + + const files = createMockFiles(EXPAND_UPLOADS_MANAGER_ITEMS_NUM_THRESHOLD + 1); + wrapper.setProps({ files }); + + expect(wrapper.state().isUploadsManagerExpanded).toBe(false); + // Auto-expand must not leak into the persisted preference via onToggle. + expect(onToggle).not.toHaveBeenCalled(); + }); + + test('controlled-mode collapse runs minimizeUploadsManager side effects (onMinimize, cleanup timer, isAutoExpanded reset)', () => { + jest.useFakeTimers(); + const onToggle = jest.fn(); + const onMinimize = jest.fn(); + const wrapper = getWrapper({ + enableModernizedUploads: true, + useUploadsManager: true, + isExpanded: true, + onToggle, + onMinimize, + }); + const instance = wrapper.instance(); + instance.isAutoExpanded = true; + instance.checkClearUploadItems = jest.fn(); + + instance.toggleUploadsManager(); + + expect(onToggle).toHaveBeenCalledWith(false); + expect(onMinimize).toHaveBeenCalledTimes(1); + expect(instance.isAutoExpanded).toBe(false); + expect(instance.checkClearUploadItems).toHaveBeenCalledTimes(1); + // Internal state still owned by consumer — untouched. + expect(wrapper.state().isUploadsManagerExpanded).toBe(false); + jest.useRealTimers(); + }); + + test('auto-collapse via resetUploadManagerExpandState is a no-op in controlled mode', () => { + const onToggle = jest.fn(); + const wrapper = getWrapper({ + useUploadsManager: true, + isExpanded: true, + onToggle, + }); + const instance = wrapper.instance(); + instance.isAutoExpanded = true; + + instance.resetUploadManagerExpandState(); + + expect(instance.isAutoExpanded).toBe(false); + expect(wrapper.state().isUploadsManagerExpanded).toBe(false); // initial default + expect(onToggle).not.toHaveBeenCalled(); + }); + }); + describe('componentDidMount()', () => { test('adds files to upload queue if isPrepopulateFilesEnabled is true and files are provided', () => { const files = createMockFiles(3);