From 0d25d530f1ba6a079cc7bd919d11ef89acf8f138 Mon Sep 17 00:00:00 2001 From: smarcet Date: Thu, 9 Apr 2026 22:30:33 -0300 Subject: [PATCH 1/6] fix(show-confirm-dialog): eliminate react-dom/client build error on React 16 The webpackIgnore magic comment on the dynamic import("react-dom/client") was stripped from the compiled UMD output, causing consuming projects on React 16 to fail with "Module not found: Can't resolve 'react-dom/client'". Replace the version-detection approach with a bridge pattern: - Add ConfirmDialogProvider that renders dialogs inside the existing React tree, removing the need for createRoot or ReactDOM.render entirely - showConfirmDialog() delegates to the provider when mounted - Falls back to ReactDOM.render() with a console warning for apps that haven't added the provider yet (React 16/17/18 backward compat) - Zero references to react-dom/client remain in source or compiled output --- src/components/index.js | 1 + src/components/mui/ConfirmDialogProvider.js | 92 ++++++++ .../__tests__/confirm-dialog-provider.test.js | 202 ++++++++++++++++++ .../mui/__tests__/show-confirm-dialog.test.js | 108 ++++++++-- src/components/mui/showConfirmDialog.js | 117 +++++++--- webpack.common.js | 1 + 6 files changed, 467 insertions(+), 54 deletions(-) create mode 100644 src/components/mui/ConfirmDialogProvider.js create mode 100644 src/components/mui/__tests__/confirm-dialog-provider.test.js diff --git a/src/components/index.js b/src/components/index.js index ea38031e..87b28aaa 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -63,6 +63,7 @@ export {default as MuiChipList} from './mui/chip-list' export {default as MuiChipNotify} from './mui/chip-notify' export {default as MuiChipSelectInput} from './mui/chip-select-input' export {default as MuiConfirmDialog} from './mui/confirm-dialog' +export {default as ConfirmDialogProvider} from './mui/ConfirmDialogProvider' export {default as MuiCustomAlert} from './mui/custom-alert' export {default as MuiDndList} from './mui/dnd-list' export {default as MuiDropdownCheckbox} from './mui/dropdown-checkbox' diff --git a/src/components/mui/ConfirmDialogProvider.js b/src/components/mui/ConfirmDialogProvider.js new file mode 100644 index 00000000..20ca6d85 --- /dev/null +++ b/src/components/mui/ConfirmDialogProvider.js @@ -0,0 +1,92 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import React, { useState, useEffect } from "react"; +import PropTypes from "prop-types"; +import ConfirmDialog from "./confirm-dialog"; +import { _registerBridge, _unregisterBridge } from "./showConfirmDialog"; + +/** + * Provider component that manages ConfirmDialog state within the React tree. + * This eliminates the need for react-dom/client and works with React 16/17/18/19. + * + * Usage: + * + * + * + * + * Then call showConfirmDialog() anywhere in the app. + */ +const ConfirmDialogProvider = ({ children }) => { + const [dialogState, setDialogState] = useState(null); + + useEffect(() => { + // Register the bridge callback when the provider mounts + const bridgeCallback = (options) => { + return new Promise((resolve) => { + setDialogState({ + ...options, + open: true, + onResolve: resolve + }); + }); + }; + + _registerBridge(bridgeCallback); + + // Unregister when the provider unmounts + return () => { + _unregisterBridge(); + }; + }, []); + + const handleConfirm = () => { + if (dialogState?.onResolve) { + dialogState.onResolve(true); + } + setDialogState(null); + }; + + const handleCancel = () => { + if (dialogState?.onResolve) { + dialogState.onResolve(false); + } + setDialogState(null); + }; + + return ( + <> + {children} + {dialogState && ( + + )} + + ); +}; + +ConfirmDialogProvider.propTypes = { + children: PropTypes.node.isRequired +}; + +export default ConfirmDialogProvider; diff --git a/src/components/mui/__tests__/confirm-dialog-provider.test.js b/src/components/mui/__tests__/confirm-dialog-provider.test.js new file mode 100644 index 00000000..30a152a3 --- /dev/null +++ b/src/components/mui/__tests__/confirm-dialog-provider.test.js @@ -0,0 +1,202 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import React from "react"; +import { render, screen, waitFor, act } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import ConfirmDialogProvider from "../ConfirmDialogProvider"; +import showConfirmDialog from "../showConfirmDialog"; + +describe("ConfirmDialogProvider", () => { + test("renders children", () => { + render( + +
Test Content
+
+ ); + + expect(screen.getByText("Test Content")).toBeInTheDocument(); + }); + + test("shows dialog when showConfirmDialog is called", async () => { + render( + +
App Content
+
+ ); + + // Call showConfirmDialog wrapped in act + let promise; + await act(async () => { + promise = showConfirmDialog({ + title: "Confirm Action", + text: "Are you sure?" + }); + }); + + // Dialog should appear + await waitFor(() => { + expect(screen.getByText("Confirm Action")).toBeInTheDocument(); + expect(screen.getByText("Are you sure?")).toBeInTheDocument(); + }); + + // Promise should not resolve yet + let resolved = false; + promise.then(() => { + resolved = true; + }); + await new Promise((r) => setTimeout(r, 10)); + expect(resolved).toBe(false); + }); + + test("resolves true when confirm button is clicked", async () => { + const user = userEvent.setup(); + + render( + +
App Content
+
+ ); + + let promise; + await act(async () => { + promise = showConfirmDialog({ + title: "Delete Item", + text: "This cannot be undone", + confirmButtonText: "Delete" + }); + }); + + await waitFor(() => { + expect(screen.getByText("Delete Item")).toBeInTheDocument(); + }); + + const deleteButton = screen.getByRole("button", { name: "Delete" }); + await user.click(deleteButton); + + const result = await promise; + expect(result).toBe(true); + + // Dialog should be hidden after confirm + await waitFor(() => { + expect(screen.queryByText("Delete Item")).not.toBeInTheDocument(); + }); + }); + + test("resolves false when cancel button is clicked", async () => { + const user = userEvent.setup(); + + render( + +
App Content
+
+ ); + + let promise; + await act(async () => { + promise = showConfirmDialog({ + title: "Cancel Operation", + text: "Do you want to cancel?", + cancelButtonText: "No" + }); + }); + + await waitFor(() => { + expect(screen.getByText("Cancel Operation")).toBeInTheDocument(); + }); + + const noButton = screen.getByRole("button", { name: "No" }); + await user.click(noButton); + + const result = await promise; + expect(result).toBe(false); + + // Dialog should be hidden after cancel + await waitFor(() => { + expect(screen.queryByText("Cancel Operation")).not.toBeInTheDocument(); + }); + }); + + test("handles multiple sequential dialogs", async () => { + const user = userEvent.setup(); + + render( + +
App Content
+
+ ); + + // First dialog + let promise1; + await act(async () => { + promise1 = showConfirmDialog({ + title: "First Dialog", + text: "First message" + }); + }); + + await waitFor(() => { + expect(screen.getByText("First Dialog")).toBeInTheDocument(); + }); + + const confirmButton1 = screen.getByRole("button", { name: "Confirm" }); + await user.click(confirmButton1); + + const result1 = await promise1; + expect(result1).toBe(true); + + // Second dialog + let promise2; + await act(async () => { + promise2 = showConfirmDialog({ + title: "Second Dialog", + text: "Second message" + }); + }); + + await waitFor(() => { + expect(screen.getByText("Second Dialog")).toBeInTheDocument(); + }); + + const cancelButton2 = screen.getByRole("button", { name: "Cancel" }); + await user.click(cancelButton2); + + const result2 = await promise2; + expect(result2).toBe(false); + }); + + test("passes custom button colors and text", async () => { + render( + +
App Content
+
+ ); + + await act(async () => { + showConfirmDialog({ + title: "Custom Dialog", + text: "Custom buttons", + confirmButtonText: "Yes, Do It", + cancelButtonText: "No, Stop", + confirmButtonColor: "error", + cancelButtonColor: "secondary" + }); + }); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Yes, Do It" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "No, Stop" })).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/mui/__tests__/show-confirm-dialog.test.js b/src/components/mui/__tests__/show-confirm-dialog.test.js index d547912b..8ce22a21 100644 --- a/src/components/mui/__tests__/show-confirm-dialog.test.js +++ b/src/components/mui/__tests__/show-confirm-dialog.test.js @@ -21,39 +21,101 @@ jest.mock("../confirm-dialog", () => { return { __esModule: true, default: () =>
}; }); -import showConfirmDialog from "../showConfirmDialog"; +import showConfirmDialog, { _registerBridge, _unregisterBridge } from "../showConfirmDialog"; import ReactDOM from "react-dom"; describe("showConfirmDialog", () => { - beforeEach(() => jest.clearAllMocks()); + let consoleWarnSpy; - test("returns a Promise", () => { - const result = showConfirmDialog({ title: "Test", text: "Body" }); - expect(result).toBeInstanceOf(Promise); + beforeEach(() => { + jest.clearAllMocks(); + _unregisterBridge(); // Clear any registered bridge + consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(); }); - test("calls ReactDOM.render to mount the dialog", () => { - showConfirmDialog({ title: "Test", text: "Body" }); - expect(ReactDOM.render).toHaveBeenCalledTimes(1); + afterEach(() => { + consoleWarnSpy.mockRestore(); }); - test("appends a container div to the document body", () => { - const initialChildCount = document.body.children.length; - showConfirmDialog({ title: "Test", text: "Body" }); - expect(document.body.children.length).toBeGreaterThan(initialChildCount); + test("regression: does not reference react-dom/client", () => { + // This test verifies that importing showConfirmDialog does not trigger + // any reference to react-dom/client, which would cause build errors on React 16 + const moduleCode = showConfirmDialog.toString(); + expect(moduleCode).not.toContain("react-dom/client"); }); - test("passes title and text to ConfirmDialog", () => { - showConfirmDialog({ - title: "My Title", - text: "My Text", - confirmButtonText: "Yes", - cancelButtonText: "No" + describe("fallback mode (no provider)", () => { + test("returns a Promise", () => { + const result = showConfirmDialog({ title: "Test", text: "Body" }); + expect(result).toBeInstanceOf(Promise); + }); + + test("calls ReactDOM.render to mount the dialog", () => { + showConfirmDialog({ title: "Test", text: "Body" }); + expect(ReactDOM.render).toHaveBeenCalledTimes(1); + }); + + test("appends a container div to the document body", () => { + const initialChildCount = document.body.children.length; + showConfirmDialog({ title: "Test", text: "Body" }); + expect(document.body.children.length).toBeGreaterThan(initialChildCount); + }); + + test("passes title and text to ConfirmDialog", () => { + showConfirmDialog({ + title: "My Title", + text: "My Text", + confirmButtonText: "Yes", + cancelButtonText: "No" + }); + const [element] = ReactDOM.render.mock.calls[0]; + expect(element.props.title).toBe("My Title"); + expect(element.props.text).toBe("My Text"); + expect(element.props.confirmButtonText).toBe("Yes"); + expect(element.props.cancelButtonText).toBe("No"); + }); + + test("logs console warning suggesting provider migration", () => { + showConfirmDialog({ title: "Test", text: "Body" }); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("ConfirmDialogProvider") + ); + }); + }); + + describe("bridge mode (with provider)", () => { + test("delegates to registered bridge", async () => { + const mockBridge = jest.fn().mockResolvedValue(true); + _registerBridge(mockBridge); + + const promise = showConfirmDialog({ + title: "Bridge Test", + text: "Using bridge" + }); + + expect(mockBridge).toHaveBeenCalledWith({ + title: "Bridge Test", + text: "Using bridge", + iconType: "", + confirmButtonText: "Confirm", + cancelButtonText: "Cancel", + confirmButtonColor: "primary", + cancelButtonColor: "primary" + }); + + const result = await promise; + expect(result).toBe(true); + expect(ReactDOM.render).not.toHaveBeenCalled(); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + test("does not use ReactDOM.render when bridge is registered", () => { + const mockBridge = jest.fn().mockResolvedValue(false); + _registerBridge(mockBridge); + + showConfirmDialog({ title: "Test", text: "Body" }); + + expect(ReactDOM.render).not.toHaveBeenCalled(); }); - const [element] = ReactDOM.render.mock.calls[0]; - expect(element.props.title).toBe("My Title"); - expect(element.props.text).toBe("My Text"); - expect(element.props.confirmButtonText).toBe("Yes"); - expect(element.props.cancelButtonText).toBe("No"); }); }); diff --git a/src/components/mui/showConfirmDialog.js b/src/components/mui/showConfirmDialog.js index 673a2445..19f00526 100644 --- a/src/components/mui/showConfirmDialog.js +++ b/src/components/mui/showConfirmDialog.js @@ -11,27 +11,71 @@ * limitations under the License. * */ +/** + * REACT 19 USAGE: + * + * For React 19 projects (where ReactDOM.render() was removed), wrap your app with the provider: + * + * import { ConfirmDialogProvider } from 'openstack-uicore-foundation'; + * + * function App() { + * return ( + * + * + * + * ); + * } + * + * Then use showConfirmDialog() anywhere in your app: + * + * import { MuiShowConfirmDialog } from 'openstack-uicore-foundation'; + * + * const confirmed = await MuiShowConfirmDialog({ + * title: 'Delete Item?', + * text: 'This cannot be undone' + * }); + * + * The provider renders dialogs inside the React tree using hooks (no createRoot or ReactDOM.render needed). + * + * WITHOUT PROVIDER (React 16/17/18 fallback): + * Falls back to ReactDOM.render() - works on React 16/17/18, logs warning suggesting provider migration. + * Not compatible with React 19 - provider is required. + */ + import ReactDOM from "react-dom"; import React from "react"; import ConfirmDialog from "./confirm-dialog"; -// Lazy-loaded createRoot for React 18+. -// Cached after first call so the dynamic import only runs once. -let createRootFn = undefined; // undefined = not yet checked +// Bridge pattern: module-level variable to hold the provider's callback +let bridgeFn = null; -async function getCreateRoot() { - if (createRootFn !== undefined) return createRootFn; - try { - // webpackIgnore prevents webpack from resolving this at build time, - // so consuming projects on React 16/17 won't get a "Module not found" error. - const mod = await import(/* webpackIgnore: true */ "react-dom/client"); - createRootFn = mod.createRoot || null; - } catch (_) { - createRootFn = null; - } - return createRootFn; +/** + * Register the bridge callback (called by ConfirmDialogProvider on mount) + * @private - exported for testing only + */ +export function _registerBridge(callback) { + bridgeFn = callback; +} + +/** + * Unregister the bridge callback (called by ConfirmDialogProvider on unmount) + * @private - exported for testing only + */ +export function _unregisterBridge() { + bridgeFn = null; } +/** + * @param param0 + * @param param0.title + * @param param0.text + * @param param0.iconType + * @param param0.confirmButtonText + * @param param0.cancelButtonText + * @param param0.confirmButtonColor + * @param param0.cancelButtonColor + * @returns {*|Promise} + */ const showConfirmDialog = ({ title, text, @@ -40,19 +84,36 @@ const showConfirmDialog = ({ cancelButtonText = "Cancel", confirmButtonColor = "primary", cancelButtonColor = "primary" -}) => - new Promise((resolve) => { +}) => { + const options = { + title, + text, + iconType, + confirmButtonText, + cancelButtonText, + confirmButtonColor, + cancelButtonColor + }; + + // If bridge is registered (provider is mounted), use it + if (bridgeFn) { + return bridgeFn(options); + } + + // Fallback to ReactDOM.render for backward compatibility + // This path is used when consuming apps haven't migrated to ConfirmDialogProvider yet + console.warn( + "[openstack-uicore-foundation] showConfirmDialog: ConfirmDialogProvider is not mounted. " + + "For better React 16/17/18/19 compatibility, wrap your app with . " + + "Falling back to ReactDOM.render." + ); + + return new Promise((resolve) => { const container = document.createElement("div"); document.body.appendChild(container); - let root = null; - const close = (answer) => { - if (root) { - root.unmount(); - } else { - ReactDOM.unmountComponentAtNode(container); - } + ReactDOM.unmountComponentAtNode(container); container.remove(); resolve(answer); }; @@ -75,14 +136,8 @@ const showConfirmDialog = ({ /> ); - getCreateRoot().then((createRoot) => { - if (createRoot) { - root = createRoot(container); - root.render(element); - } else { - ReactDOM.render(element, container); - } - }); + ReactDOM.render(element, container); }); +}; export default showConfirmDialog; diff --git a/webpack.common.js b/webpack.common.js index 7b9ecd99..56b4631a 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -84,6 +84,7 @@ module.exports = { 'components/mui/chip-notify': './src/components/mui/chip-notify.js', 'components/mui/chip-select-input': './src/components/mui/chip-select-input.js', 'components/mui/confirm-dialog': './src/components/mui/confirm-dialog.js', + 'components/mui/confirm-dialog-provider': './src/components/mui/ConfirmDialogProvider.js', 'components/mui/custom-alert': './src/components/mui/custom-alert.js', 'components/mui/dnd-list': './src/components/mui/dnd-list.js', 'components/mui/dropdown-checkbox': './src/components/mui/dropdown-checkbox.js', From f9d18e6eca1ec6935c9da5910a78641fede9028a Mon Sep 17 00:00:00 2001 From: smarcet Date: Thu, 9 Apr 2026 22:33:55 -0300 Subject: [PATCH 2/6] v5.0.8-beta.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 653c2a42..c2f9f356 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openstack-uicore-foundation", - "version": "5.0.6", + "version": "5.0.8-beta.1", "description": "ui reactjs components for openstack marketing site", "main": "lib/openstack-uicore-foundation.js", "scripts": { From 729d95eb64f31faa540743c291d5e26ae1f9cbcc Mon Sep 17 00:00:00 2001 From: smarcet Date: Fri, 10 Apr 2026 12:19:24 -0300 Subject: [PATCH 3/6] fix(show-confirm-dialog): eliminate react-dom/client dependency for React 16 compat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace ReactDOM.render fallback with bridge pattern. GlobalConfirmDialog component and showConfirmDialog function live in a single module. showConfirmDialog requires at the app root — throws a clear error otherwise. No react-dom imports remain. BREAKING CHANGE: must be rendered at the app root. --- src/components/index.js | 2 +- src/components/mui/ConfirmDialogProvider.js | 92 ---------- ....test.js => global-confirm-dialog.test.js} | 102 +++-------- .../mui/__tests__/show-confirm-dialog.test.js | 102 +---------- src/components/mui/showConfirmDialog.js | 161 +++++++++--------- webpack.common.js | 2 +- 6 files changed, 104 insertions(+), 357 deletions(-) delete mode 100644 src/components/mui/ConfirmDialogProvider.js rename src/components/mui/__tests__/{confirm-dialog-provider.test.js => global-confirm-dialog.test.js} (59%) diff --git a/src/components/index.js b/src/components/index.js index 87b28aaa..5c8919ef 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -63,7 +63,7 @@ export {default as MuiChipList} from './mui/chip-list' export {default as MuiChipNotify} from './mui/chip-notify' export {default as MuiChipSelectInput} from './mui/chip-select-input' export {default as MuiConfirmDialog} from './mui/confirm-dialog' -export {default as ConfirmDialogProvider} from './mui/ConfirmDialogProvider' +export {GlobalConfirmDialog} from './mui/showConfirmDialog' export {default as MuiCustomAlert} from './mui/custom-alert' export {default as MuiDndList} from './mui/dnd-list' export {default as MuiDropdownCheckbox} from './mui/dropdown-checkbox' diff --git a/src/components/mui/ConfirmDialogProvider.js b/src/components/mui/ConfirmDialogProvider.js deleted file mode 100644 index 20ca6d85..00000000 --- a/src/components/mui/ConfirmDialogProvider.js +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Copyright 2026 OpenStack Foundation - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * */ - -import React, { useState, useEffect } from "react"; -import PropTypes from "prop-types"; -import ConfirmDialog from "./confirm-dialog"; -import { _registerBridge, _unregisterBridge } from "./showConfirmDialog"; - -/** - * Provider component that manages ConfirmDialog state within the React tree. - * This eliminates the need for react-dom/client and works with React 16/17/18/19. - * - * Usage: - * - * - * - * - * Then call showConfirmDialog() anywhere in the app. - */ -const ConfirmDialogProvider = ({ children }) => { - const [dialogState, setDialogState] = useState(null); - - useEffect(() => { - // Register the bridge callback when the provider mounts - const bridgeCallback = (options) => { - return new Promise((resolve) => { - setDialogState({ - ...options, - open: true, - onResolve: resolve - }); - }); - }; - - _registerBridge(bridgeCallback); - - // Unregister when the provider unmounts - return () => { - _unregisterBridge(); - }; - }, []); - - const handleConfirm = () => { - if (dialogState?.onResolve) { - dialogState.onResolve(true); - } - setDialogState(null); - }; - - const handleCancel = () => { - if (dialogState?.onResolve) { - dialogState.onResolve(false); - } - setDialogState(null); - }; - - return ( - <> - {children} - {dialogState && ( - - )} - - ); -}; - -ConfirmDialogProvider.propTypes = { - children: PropTypes.node.isRequired -}; - -export default ConfirmDialogProvider; diff --git a/src/components/mui/__tests__/confirm-dialog-provider.test.js b/src/components/mui/__tests__/global-confirm-dialog.test.js similarity index 59% rename from src/components/mui/__tests__/confirm-dialog-provider.test.js rename to src/components/mui/__tests__/global-confirm-dialog.test.js index 30a152a3..6e8d9b7a 100644 --- a/src/components/mui/__tests__/confirm-dialog-provider.test.js +++ b/src/components/mui/__tests__/global-confirm-dialog.test.js @@ -15,28 +15,17 @@ import React from "react"; import { render, screen, waitFor, act } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import "@testing-library/jest-dom"; -import ConfirmDialogProvider from "../ConfirmDialogProvider"; -import showConfirmDialog from "../showConfirmDialog"; - -describe("ConfirmDialogProvider", () => { - test("renders children", () => { - render( - -
Test Content
-
- ); - - expect(screen.getByText("Test Content")).toBeInTheDocument(); +import showConfirmDialog, { GlobalConfirmDialog } from "../showConfirmDialog"; + +describe("GlobalConfirmDialog", () => { + test("renders nothing when no dialog is active", () => { + const { container } = render(); + expect(container.innerHTML).toBe(""); }); test("shows dialog when showConfirmDialog is called", async () => { - render( - -
App Content
-
- ); + render(); - // Call showConfirmDialog wrapped in act let promise; await act(async () => { promise = showConfirmDialog({ @@ -45,29 +34,20 @@ describe("ConfirmDialogProvider", () => { }); }); - // Dialog should appear await waitFor(() => { expect(screen.getByText("Confirm Action")).toBeInTheDocument(); expect(screen.getByText("Are you sure?")).toBeInTheDocument(); }); - // Promise should not resolve yet let resolved = false; - promise.then(() => { - resolved = true; - }); + promise.then(() => { resolved = true; }); await new Promise((r) => setTimeout(r, 10)); expect(resolved).toBe(false); }); test("resolves true when confirm button is clicked", async () => { const user = userEvent.setup(); - - render( - -
App Content
-
- ); + render(); let promise; await act(async () => { @@ -82,13 +62,11 @@ describe("ConfirmDialogProvider", () => { expect(screen.getByText("Delete Item")).toBeInTheDocument(); }); - const deleteButton = screen.getByRole("button", { name: "Delete" }); - await user.click(deleteButton); + await user.click(screen.getByRole("button", { name: "Delete" })); const result = await promise; expect(result).toBe(true); - // Dialog should be hidden after confirm await waitFor(() => { expect(screen.queryByText("Delete Item")).not.toBeInTheDocument(); }); @@ -96,12 +74,7 @@ describe("ConfirmDialogProvider", () => { test("resolves false when cancel button is clicked", async () => { const user = userEvent.setup(); - - render( - -
App Content
-
- ); + render(); let promise; await act(async () => { @@ -116,13 +89,11 @@ describe("ConfirmDialogProvider", () => { expect(screen.getByText("Cancel Operation")).toBeInTheDocument(); }); - const noButton = screen.getByRole("button", { name: "No" }); - await user.click(noButton); + await user.click(screen.getByRole("button", { name: "No" })); const result = await promise; expect(result).toBe(false); - // Dialog should be hidden after cancel await waitFor(() => { expect(screen.queryByText("Cancel Operation")).not.toBeInTheDocument(); }); @@ -130,58 +101,29 @@ describe("ConfirmDialogProvider", () => { test("handles multiple sequential dialogs", async () => { const user = userEvent.setup(); + render(); - render( - -
App Content
-
- ); - - // First dialog let promise1; await act(async () => { - promise1 = showConfirmDialog({ - title: "First Dialog", - text: "First message" - }); + promise1 = showConfirmDialog({ title: "First Dialog", text: "First message" }); }); - await waitFor(() => { - expect(screen.getByText("First Dialog")).toBeInTheDocument(); - }); + await waitFor(() => { expect(screen.getByText("First Dialog")).toBeInTheDocument(); }); + await user.click(screen.getByRole("button", { name: "Confirm" })); + expect(await promise1).toBe(true); - const confirmButton1 = screen.getByRole("button", { name: "Confirm" }); - await user.click(confirmButton1); - - const result1 = await promise1; - expect(result1).toBe(true); - - // Second dialog let promise2; await act(async () => { - promise2 = showConfirmDialog({ - title: "Second Dialog", - text: "Second message" - }); + promise2 = showConfirmDialog({ title: "Second Dialog", text: "Second message" }); }); - await waitFor(() => { - expect(screen.getByText("Second Dialog")).toBeInTheDocument(); - }); - - const cancelButton2 = screen.getByRole("button", { name: "Cancel" }); - await user.click(cancelButton2); - - const result2 = await promise2; - expect(result2).toBe(false); + await waitFor(() => { expect(screen.getByText("Second Dialog")).toBeInTheDocument(); }); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(await promise2).toBe(false); }); test("passes custom button colors and text", async () => { - render( - -
App Content
-
- ); + render(); await act(async () => { showConfirmDialog({ diff --git a/src/components/mui/__tests__/show-confirm-dialog.test.js b/src/components/mui/__tests__/show-confirm-dialog.test.js index 8ce22a21..555c950e 100644 --- a/src/components/mui/__tests__/show-confirm-dialog.test.js +++ b/src/components/mui/__tests__/show-confirm-dialog.test.js @@ -11,111 +11,17 @@ * limitations under the License. * */ -jest.mock("react-dom", () => ({ - render: jest.fn(), - unmountComponentAtNode: jest.fn() -})); - -jest.mock("../confirm-dialog", () => { - const React = require("react"); - return { __esModule: true, default: () =>
}; -}); - -import showConfirmDialog, { _registerBridge, _unregisterBridge } from "../showConfirmDialog"; -import ReactDOM from "react-dom"; +import showConfirmDialog from "../showConfirmDialog"; describe("showConfirmDialog", () => { - let consoleWarnSpy; - - beforeEach(() => { - jest.clearAllMocks(); - _unregisterBridge(); // Clear any registered bridge - consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(); - }); - - afterEach(() => { - consoleWarnSpy.mockRestore(); - }); - test("regression: does not reference react-dom/client", () => { - // This test verifies that importing showConfirmDialog does not trigger - // any reference to react-dom/client, which would cause build errors on React 16 const moduleCode = showConfirmDialog.toString(); expect(moduleCode).not.toContain("react-dom/client"); }); - describe("fallback mode (no provider)", () => { - test("returns a Promise", () => { - const result = showConfirmDialog({ title: "Test", text: "Body" }); - expect(result).toBeInstanceOf(Promise); - }); - - test("calls ReactDOM.render to mount the dialog", () => { + test("throws when GlobalConfirmDialog is not mounted", () => { + expect(() => { showConfirmDialog({ title: "Test", text: "Body" }); - expect(ReactDOM.render).toHaveBeenCalledTimes(1); - }); - - test("appends a container div to the document body", () => { - const initialChildCount = document.body.children.length; - showConfirmDialog({ title: "Test", text: "Body" }); - expect(document.body.children.length).toBeGreaterThan(initialChildCount); - }); - - test("passes title and text to ConfirmDialog", () => { - showConfirmDialog({ - title: "My Title", - text: "My Text", - confirmButtonText: "Yes", - cancelButtonText: "No" - }); - const [element] = ReactDOM.render.mock.calls[0]; - expect(element.props.title).toBe("My Title"); - expect(element.props.text).toBe("My Text"); - expect(element.props.confirmButtonText).toBe("Yes"); - expect(element.props.cancelButtonText).toBe("No"); - }); - - test("logs console warning suggesting provider migration", () => { - showConfirmDialog({ title: "Test", text: "Body" }); - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining("ConfirmDialogProvider") - ); - }); - }); - - describe("bridge mode (with provider)", () => { - test("delegates to registered bridge", async () => { - const mockBridge = jest.fn().mockResolvedValue(true); - _registerBridge(mockBridge); - - const promise = showConfirmDialog({ - title: "Bridge Test", - text: "Using bridge" - }); - - expect(mockBridge).toHaveBeenCalledWith({ - title: "Bridge Test", - text: "Using bridge", - iconType: "", - confirmButtonText: "Confirm", - cancelButtonText: "Cancel", - confirmButtonColor: "primary", - cancelButtonColor: "primary" - }); - - const result = await promise; - expect(result).toBe(true); - expect(ReactDOM.render).not.toHaveBeenCalled(); - expect(consoleWarnSpy).not.toHaveBeenCalled(); - }); - - test("does not use ReactDOM.render when bridge is registered", () => { - const mockBridge = jest.fn().mockResolvedValue(false); - _registerBridge(mockBridge); - - showConfirmDialog({ title: "Test", text: "Body" }); - - expect(ReactDOM.render).not.toHaveBeenCalled(); - }); + }).toThrow(""); }); }); diff --git a/src/components/mui/showConfirmDialog.js b/src/components/mui/showConfirmDialog.js index 19f00526..73397147 100644 --- a/src/components/mui/showConfirmDialog.js +++ b/src/components/mui/showConfirmDialog.js @@ -11,60 +11,38 @@ * limitations under the License. * */ +import React, { useState, useEffect } from "react"; +import ConfirmDialog from "./confirm-dialog"; + /** - * REACT 19 USAGE: - * - * For React 19 projects (where ReactDOM.render() was removed), wrap your app with the provider: - * - * import { ConfirmDialogProvider } from 'openstack-uicore-foundation'; - * - * function App() { - * return ( - * - * - * - * ); - * } + * Imperative confirm dialog API. * - * Then use showConfirmDialog() anywhere in your app: + * SETUP (required): + * Place at the root of your app: * - * import { MuiShowConfirmDialog } from 'openstack-uicore-foundation'; + * import { GlobalConfirmDialog } from 'openstack-uicore-foundation'; * - * const confirmed = await MuiShowConfirmDialog({ - * title: 'Delete Item?', - * text: 'This cannot be undone' - * }); + * function App() { + * return ( + * <> + * + * + * + * ); + * } * - * The provider renders dialogs inside the React tree using hooks (no createRoot or ReactDOM.render needed). + * USAGE: + * import { MuiShowConfirmDialog } from 'openstack-uicore-foundation'; * - * WITHOUT PROVIDER (React 16/17/18 fallback): - * Falls back to ReactDOM.render() - works on React 16/17/18, logs warning suggesting provider migration. - * Not compatible with React 19 - provider is required. + * const confirmed = await MuiShowConfirmDialog({ + * title: 'Delete Item?', + * text: 'This cannot be undone' + * }); */ -import ReactDOM from "react-dom"; -import React from "react"; -import ConfirmDialog from "./confirm-dialog"; - -// Bridge pattern: module-level variable to hold the provider's callback +// Module-level bridge: holds the callback registered by GlobalConfirmDialog let bridgeFn = null; -/** - * Register the bridge callback (called by ConfirmDialogProvider on mount) - * @private - exported for testing only - */ -export function _registerBridge(callback) { - bridgeFn = callback; -} - -/** - * Unregister the bridge callback (called by ConfirmDialogProvider on unmount) - * @private - exported for testing only - */ -export function _unregisterBridge() { - bridgeFn = null; -} - /** * @param param0 * @param param0.title @@ -74,7 +52,7 @@ export function _unregisterBridge() { * @param param0.cancelButtonText * @param param0.confirmButtonColor * @param param0.cancelButtonColor - * @returns {*|Promise} + * @returns {Promise} */ const showConfirmDialog = ({ title, @@ -85,7 +63,14 @@ const showConfirmDialog = ({ confirmButtonColor = "primary", cancelButtonColor = "primary" }) => { - const options = { + if (!bridgeFn) { + throw new Error( + "[openstack-uicore-foundation] showConfirmDialog: is not mounted. " + + "Add to the root of your app." + ); + } + + return bridgeFn({ title, text, iconType, @@ -93,51 +78,57 @@ const showConfirmDialog = ({ cancelButtonText, confirmButtonColor, cancelButtonColor - }; - - // If bridge is registered (provider is mounted), use it - if (bridgeFn) { - return bridgeFn(options); - } - - // Fallback to ReactDOM.render for backward compatibility - // This path is used when consuming apps haven't migrated to ConfirmDialogProvider yet - console.warn( - "[openstack-uicore-foundation] showConfirmDialog: ConfirmDialogProvider is not mounted. " + - "For better React 16/17/18/19 compatibility, wrap your app with . " + - "Falling back to ReactDOM.render." - ); + }); +}; - return new Promise((resolve) => { - const container = document.createElement("div"); - document.body.appendChild(container); +/** + * Global confirm dialog component. Place at the root of your app: + * + * + * ... + * + * + * + * Then call showConfirmDialog() anywhere. + */ +export const GlobalConfirmDialog = () => { + const [dialogState, setDialogState] = useState(null); - const close = (answer) => { - ReactDOM.unmountComponentAtNode(container); - container.remove(); - resolve(answer); + useEffect(() => { + bridgeFn = (options) => { + return new Promise((resolve) => { + setDialogState({ ...options, open: true, onResolve: resolve }); + }); }; + return () => { bridgeFn = null; }; + }, []); - const handleConfirm = () => close(true); - const handleCancel = () => close(false); + const handleConfirm = () => { + if (dialogState?.onResolve) dialogState.onResolve(true); + setDialogState(null); + }; - const element = ( - - ); + const handleCancel = () => { + if (dialogState?.onResolve) dialogState.onResolve(false); + setDialogState(null); + }; - ReactDOM.render(element, container); - }); + if (!dialogState) return null; + + return ( + + ); }; export default showConfirmDialog; diff --git a/webpack.common.js b/webpack.common.js index 56b4631a..040fa5e9 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -84,7 +84,7 @@ module.exports = { 'components/mui/chip-notify': './src/components/mui/chip-notify.js', 'components/mui/chip-select-input': './src/components/mui/chip-select-input.js', 'components/mui/confirm-dialog': './src/components/mui/confirm-dialog.js', - 'components/mui/confirm-dialog-provider': './src/components/mui/ConfirmDialogProvider.js', + 'components/mui/global-confirm-dialog': './src/components/mui/showConfirmDialog.js', 'components/mui/custom-alert': './src/components/mui/custom-alert.js', 'components/mui/dnd-list': './src/components/mui/dnd-list.js', 'components/mui/dropdown-checkbox': './src/components/mui/dropdown-checkbox.js', From 807c4a8affa44fb1d2757395bac97448a6a232cb Mon Sep 17 00:00:00 2001 From: smarcet Date: Fri, 10 Apr 2026 12:23:29 -0300 Subject: [PATCH 4/6] v5.0.8-beta.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c2f9f356..ceb251ec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openstack-uicore-foundation", - "version": "5.0.8-beta.1", + "version": "5.0.8-beta.2", "description": "ui reactjs components for openstack marketing site", "main": "lib/openstack-uicore-foundation.js", "scripts": { From bdf399a5f018ae74d100e26c5b89bd57c9cc44ce Mon Sep 17 00:00:00 2001 From: smarcet Date: Fri, 10 Apr 2026 13:31:33 -0300 Subject: [PATCH 5/6] chore: fix referece accross to bundles --- src/components/mui/showConfirmDialog.js | 25 +++++++++++++++---------- webpack.common.js | 1 - 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/components/mui/showConfirmDialog.js b/src/components/mui/showConfirmDialog.js index 73397147..d13339ac 100644 --- a/src/components/mui/showConfirmDialog.js +++ b/src/components/mui/showConfirmDialog.js @@ -20,7 +20,8 @@ import ConfirmDialog from "./confirm-dialog"; * SETUP (required): * Place at the root of your app: * - * import { GlobalConfirmDialog } from 'openstack-uicore-foundation'; + * import { GlobalConfirmDialog } from + * 'openstack-uicore-foundation/lib/components/mui/show-confirm-dialog'; * * function App() { * return ( @@ -31,17 +32,21 @@ import ConfirmDialog from "./confirm-dialog"; * ); * } * - * USAGE: - * import { MuiShowConfirmDialog } from 'openstack-uicore-foundation'; + * USAGE (works from any file — the bridge is shared via globalThis): + * import showConfirmDialog from + * 'openstack-uicore-foundation/lib/components/mui/show-confirm-dialog'; * - * const confirmed = await MuiShowConfirmDialog({ + * const confirmed = await showConfirmDialog({ * title: 'Delete Item?', * text: 'This cannot be undone' * }); */ -// Module-level bridge: holds the callback registered by GlobalConfirmDialog -let bridgeFn = null; +// Shared bridge reference stored on globalThis so that all webpack bundles +// (table, sortable-table, show-confirm-dialog, index, etc.) read/write the +// same callback. A module-level variable would be duplicated per bundle. +const BRIDGE_KEY = "__oif_confirm_dialog_bridge__"; +const _global = typeof globalThis !== "undefined" ? globalThis : typeof window !== "undefined" ? window : {}; /** * @param param0 @@ -63,14 +68,14 @@ const showConfirmDialog = ({ confirmButtonColor = "primary", cancelButtonColor = "primary" }) => { - if (!bridgeFn) { + if (!_global[BRIDGE_KEY]) { throw new Error( "[openstack-uicore-foundation] showConfirmDialog: is not mounted. " + "Add to the root of your app." ); } - return bridgeFn({ + return _global[BRIDGE_KEY]({ title, text, iconType, @@ -95,12 +100,12 @@ export const GlobalConfirmDialog = () => { const [dialogState, setDialogState] = useState(null); useEffect(() => { - bridgeFn = (options) => { + _global[BRIDGE_KEY] = (options) => { return new Promise((resolve) => { setDialogState({ ...options, open: true, onResolve: resolve }); }); }; - return () => { bridgeFn = null; }; + return () => { _global[BRIDGE_KEY] = null; }; }, []); const handleConfirm = () => { diff --git a/webpack.common.js b/webpack.common.js index 040fa5e9..7b9ecd99 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -84,7 +84,6 @@ module.exports = { 'components/mui/chip-notify': './src/components/mui/chip-notify.js', 'components/mui/chip-select-input': './src/components/mui/chip-select-input.js', 'components/mui/confirm-dialog': './src/components/mui/confirm-dialog.js', - 'components/mui/global-confirm-dialog': './src/components/mui/showConfirmDialog.js', 'components/mui/custom-alert': './src/components/mui/custom-alert.js', 'components/mui/dnd-list': './src/components/mui/dnd-list.js', 'components/mui/dropdown-checkbox': './src/components/mui/dropdown-checkbox.js', From ca3ba9467bf4c04f170a77c9ab027f437de5f8f9 Mon Sep 17 00:00:00 2001 From: smarcet Date: Fri, 10 Apr 2026 13:33:59 -0300 Subject: [PATCH 6/6] v5.0.8-beta.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ceb251ec..c31ec1f7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openstack-uicore-foundation", - "version": "5.0.8-beta.2", + "version": "5.0.8-beta.3", "description": "ui reactjs components for openstack marketing site", "main": "lib/openstack-uicore-foundation.js", "scripts": {