From 5c4d574dd3f53631e34b5e5412c235c1e29dc761 Mon Sep 17 00:00:00 2001 From: Hleb Kryshyn Date: Mon, 15 Jun 2026 16:57:56 +0200 Subject: [PATCH 1/4] feat(content-uploader): controlled expanded state --- .../content-uploader/ContentUploader.tsx | 55 ++++++++++-- .../__tests__/ContentUploader.test.js | 83 +++++++++++++++++++ 2 files changed, 133 insertions(+), 5 deletions(-) diff --git a/src/elements/content-uploader/ContentUploader.tsx b/src/elements/content-uploader/ContentUploader.tsx index 9f733cdd1f..73b55f84df 100644 --- a/src/elements/content-uploader/ContentUploader.tsx +++ b/src/elements/content-uploader/ContentUploader.tsx @@ -110,6 +110,8 @@ export interface ContentUploaderProps { uploadHost: string; useUploadsManager?: boolean; enableModernizedUploads?: boolean; + isExpanded?: boolean; + onToggle?: (isExpanded: boolean) => void; } type State = { @@ -653,7 +655,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; @@ -963,8 +965,35 @@ class ContentUploader extends Component { }); }; + /** + * Whether the component is in controlled-expand mode. + * + * @return {boolean} + */ + isExpandControlled = (): boolean => typeof this.props.isExpanded === 'boolean'; + + /** + * 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, }); @@ -1295,6 +1324,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 }); }; @@ -1335,9 +1370,17 @@ class ContentUploader extends Component { * @return {void} */ toggleUploadsManager = (): void => { - const { isUploadsManagerExpanded } = this.state; + const { onToggle } = this.props; + const isExpanded = this.getIsExpanded(); + + if (this.isExpandControlled()) { + if (onToggle) { + onToggle(!isExpanded); + } + return; + } - if (isUploadsManagerExpanded) { + if (isExpanded) { this.minimizeUploadsManager(); } else { this.expandUploadsManager(); @@ -1395,7 +1438,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 ( @@ -1455,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 931b53f4cc..0827c5ba1e 100644 --- a/src/elements/content-uploader/__tests__/ContentUploader.test.js +++ b/src/elements/content-uploader/__tests__/ContentUploader.test.js @@ -680,6 +680,89 @@ 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 does not call onToggle', () => { + const onToggle = jest.fn(); + const wrapper = getWrapper({ useUploadsManager: true, onToggle }); + + wrapper.instance().toggleUploadsManager(); + + expect(wrapper.state().isUploadsManagerExpanded).toBe(true); + expect(onToggle).not.toHaveBeenCalled(); + }); + + 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('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); From 34fb0eca7c79760b74f106bfdcd51d98c77d6dc5 Mon Sep 17 00:00:00 2001 From: Hleb Kryshyn Date: Tue, 16 Jun 2026 12:56:36 +0200 Subject: [PATCH 2/4] feat(content-uploader): run onToggle uncontrolled --- .../content-uploader/ContentUploader.tsx | 18 ++++++++---------- .../__tests__/ContentUploader.test.js | 12 ++++++++++-- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/elements/content-uploader/ContentUploader.tsx b/src/elements/content-uploader/ContentUploader.tsx index 73b55f84df..dba439c7f1 100644 --- a/src/elements/content-uploader/ContentUploader.tsx +++ b/src/elements/content-uploader/ContentUploader.tsx @@ -970,7 +970,7 @@ class ContentUploader extends Component { * * @return {boolean} */ - isExpandControlled = (): boolean => typeof this.props.isExpanded === 'boolean'; + isExpandControlled = (): boolean => this.props.isExpanded !== undefined; /** * Returns the resolved expanded state. When `isExpanded` prop is provided @@ -1372,19 +1372,17 @@ class ContentUploader extends Component { toggleUploadsManager = (): void => { const { onToggle } = this.props; const isExpanded = this.getIsExpanded(); + const nextExpanded = !isExpanded; - if (this.isExpandControlled()) { - if (onToggle) { - onToggle(!isExpanded); + if (!this.isExpandControlled()) { + if (isExpanded) { + this.minimizeUploadsManager(); + } else { + this.expandUploadsManager(); } - return; } - if (isExpanded) { - this.minimizeUploadsManager(); - } else { - this.expandUploadsManager(); - } + onToggle?.(nextExpanded); }; /** diff --git a/src/elements/content-uploader/__tests__/ContentUploader.test.js b/src/elements/content-uploader/__tests__/ContentUploader.test.js index 0827c5ba1e..39e693ea3f 100644 --- a/src/elements/content-uploader/__tests__/ContentUploader.test.js +++ b/src/elements/content-uploader/__tests__/ContentUploader.test.js @@ -713,14 +713,22 @@ describe('elements/content-uploader/ContentUploader', () => { expect(wrapper.state().isUploadsManagerExpanded).toBe(false); }); - test('toggleUploadsManager flips internal state in uncontrolled mode and does not call onToggle', () => { + 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).not.toHaveBeenCalled(); + 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', () => { From b92eb3a77261918568f7344621ca66ec14959e99 Mon Sep 17 00:00:00 2001 From: Hleb Kryshyn Date: Tue, 16 Jun 2026 15:37:23 +0200 Subject: [PATCH 3/4] test(content-uploader): controlled-mode assertions --- .../__tests__/ContentUploader.test.js | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/elements/content-uploader/__tests__/ContentUploader.test.js b/src/elements/content-uploader/__tests__/ContentUploader.test.js index 39e693ea3f..bb87f41ed4 100644 --- a/src/elements/content-uploader/__tests__/ContentUploader.test.js +++ b/src/elements/content-uploader/__tests__/ContentUploader.test.js @@ -753,6 +753,32 @@ describe('elements/content-uploader/ContentUploader', () => { 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({ From 9eaeacda97c02de9a9eae301ce49517985658e33 Mon Sep 17 00:00:00 2001 From: Hleb Kryshyn Date: Tue, 16 Jun 2026 15:58:27 +0200 Subject: [PATCH 4/4] refactor(content-uploader): simplify branching --- src/elements/content-uploader/ContentUploader.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/elements/content-uploader/ContentUploader.tsx b/src/elements/content-uploader/ContentUploader.tsx index dba439c7f1..0f2d959132 100644 --- a/src/elements/content-uploader/ContentUploader.tsx +++ b/src/elements/content-uploader/ContentUploader.tsx @@ -1374,12 +1374,10 @@ class ContentUploader extends Component { const isExpanded = this.getIsExpanded(); const nextExpanded = !isExpanded; - if (!this.isExpandControlled()) { - if (isExpanded) { - this.minimizeUploadsManager(); - } else { - this.expandUploadsManager(); - } + if (isExpanded) { + this.minimizeUploadsManager(); + } else if (!this.isExpandControlled()) { + this.expandUploadsManager(); } onToggle?.(nextExpanded);