diff --git a/package.json b/package.json index a5d984f..cf5f13f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openstack-uicore-foundation", - "version": "5.0.7", + "version": "5.0.8-beta.3", "description": "ui reactjs components for openstack marketing site", "main": "lib/openstack-uicore-foundation.js", "scripts": { diff --git a/src/components/index.js b/src/components/index.js index 9bcf931..cf94988 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 {GlobalConfirmDialog} from './mui/showConfirmDialog' export {default as MuiCustomAlert} from './mui/custom-alert' export {default as MuiDropdownCheckbox} from './mui/dropdown-checkbox' export {default as MuiMenuButton} from './mui/menu-button' diff --git a/src/components/mui/__tests__/global-confirm-dialog.test.js b/src/components/mui/__tests__/global-confirm-dialog.test.js new file mode 100644 index 0000000..6e8d9b7 --- /dev/null +++ b/src/components/mui/__tests__/global-confirm-dialog.test.js @@ -0,0 +1,144 @@ +/** + * 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 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(); + + let promise; + await act(async () => { + promise = showConfirmDialog({ + title: "Confirm Action", + text: "Are you sure?" + }); + }); + + await waitFor(() => { + expect(screen.getByText("Confirm Action")).toBeInTheDocument(); + expect(screen.getByText("Are you sure?")).toBeInTheDocument(); + }); + + 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(); + + 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(); + }); + + await user.click(screen.getByRole("button", { name: "Delete" })); + + const result = await promise; + expect(result).toBe(true); + + await waitFor(() => { + expect(screen.queryByText("Delete Item")).not.toBeInTheDocument(); + }); + }); + + test("resolves false when cancel button is clicked", async () => { + const user = userEvent.setup(); + render(); + + 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(); + }); + + await user.click(screen.getByRole("button", { name: "No" })); + + const result = await promise; + expect(result).toBe(false); + + await waitFor(() => { + expect(screen.queryByText("Cancel Operation")).not.toBeInTheDocument(); + }); + }); + + test("handles multiple sequential dialogs", async () => { + const user = userEvent.setup(); + render(); + + let promise1; + await act(async () => { + promise1 = showConfirmDialog({ title: "First Dialog", text: "First message" }); + }); + + await waitFor(() => { expect(screen.getByText("First Dialog")).toBeInTheDocument(); }); + await user.click(screen.getByRole("button", { name: "Confirm" })); + expect(await promise1).toBe(true); + + let promise2; + await act(async () => { + promise2 = showConfirmDialog({ title: "Second Dialog", text: "Second message" }); + }); + + 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(); + + 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 1e71daa..555c950 100644 --- a/src/components/mui/__tests__/show-confirm-dialog.test.js +++ b/src/components/mui/__tests__/show-confirm-dialog.test.js @@ -11,53 +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 from "../showConfirmDialog"; -import ReactDOM from "react-dom"; describe("showConfirmDialog", () => { - beforeEach(() => jest.clearAllMocks()); - - test("returns a Promise", () => { - const result = showConfirmDialog({ title: "Test", text: "Body" }); - expect(result).toBeInstanceOf(Promise); - }); - - test("calls ReactDOM.render to mount the dialog", async () => { - showConfirmDialog({ title: "Test", text: "Body" }); - await Promise.resolve(); - expect(ReactDOM.render).toHaveBeenCalledTimes(1); - }); - - test("appends a container div to the document body", async () => { - const initialChildCount = document.body.children.length; - showConfirmDialog({ title: "Test", text: "Body" }); - await Promise.resolve(); - expect(document.body.children.length).toBeGreaterThan(initialChildCount); + test("regression: does not reference react-dom/client", () => { + const moduleCode = showConfirmDialog.toString(); + expect(moduleCode).not.toContain("react-dom/client"); }); - test("passes title and text to ConfirmDialog", async () => { - showConfirmDialog({ - title: "My Title", - text: "My Text", - confirmButtonText: "Yes", - cancelButtonText: "No" - }); - await Promise.resolve(); - 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("throws when GlobalConfirmDialog is not mounted", () => { + expect(() => { + showConfirmDialog({ title: "Test", text: "Body" }); + }).toThrow(""); }); }); diff --git a/src/components/mui/showConfirmDialog.js b/src/components/mui/showConfirmDialog.js index 673a244..d13339a 100644 --- a/src/components/mui/showConfirmDialog.js +++ b/src/components/mui/showConfirmDialog.js @@ -11,27 +11,54 @@ * limitations under the License. * */ -import ReactDOM from "react-dom"; -import React from "react"; +import React, { useState, useEffect } 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 +/** + * Imperative confirm dialog API. + * + * SETUP (required): + * Place at the root of your app: + * + * import { GlobalConfirmDialog } from + * 'openstack-uicore-foundation/lib/components/mui/show-confirm-dialog'; + * + * function App() { + * return ( + * <> + * + * + * + * ); + * } + * + * 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 showConfirmDialog({ + * title: 'Delete Item?', + * text: 'This cannot be undone' + * }); + */ -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; -} +// 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 + * @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,49 +67,73 @@ const showConfirmDialog = ({ cancelButtonText = "Cancel", confirmButtonColor = "primary", cancelButtonColor = "primary" -}) => - new Promise((resolve) => { - const container = document.createElement("div"); - document.body.appendChild(container); +}) => { + if (!_global[BRIDGE_KEY]) { + throw new Error( + "[openstack-uicore-foundation] showConfirmDialog: is not mounted. " + + "Add to the root of your app." + ); + } - let root = null; + return _global[BRIDGE_KEY]({ + title, + text, + iconType, + confirmButtonText, + cancelButtonText, + confirmButtonColor, + cancelButtonColor + }); +}; - const close = (answer) => { - if (root) { - root.unmount(); - } else { - ReactDOM.unmountComponentAtNode(container); - } - container.remove(); - resolve(answer); +/** + * Global confirm dialog component. Place at the root of your app: + * + * + * ... + * + * + * + * Then call showConfirmDialog() anywhere. + */ +export const GlobalConfirmDialog = () => { + const [dialogState, setDialogState] = useState(null); + + useEffect(() => { + _global[BRIDGE_KEY] = (options) => { + return new Promise((resolve) => { + setDialogState({ ...options, open: true, onResolve: resolve }); + }); }; + return () => { _global[BRIDGE_KEY] = 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); + }; - getCreateRoot().then((createRoot) => { - if (createRoot) { - root = createRoot(container); - root.render(element); - } else { - ReactDOM.render(element, container); - } - }); - }); + if (!dialogState) return null; + + return ( + + ); +}; export default showConfirmDialog;