diff --git a/package.json b/package.json
index 105c5050..45be0341 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "openstack-uicore-foundation",
- "version": "5.0.49",
+ "version": "5.0.50-beta.1",
"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 f1dfabb5..7c377713 100644
--- a/src/components/index.js
+++ b/src/components/index.js
@@ -112,6 +112,7 @@ export {default as MuiAlertModal} from './mui/AlertModal'
export {default as MuiAuthButton} from './mui/AuthButton'
export {default as MuiCartButton} from './mui/CartButton'
export {default as MuiConfirmDeleteDialog} from './mui/ConfirmDeleteDialog'
+export {default as MuiCustomDialog} from './mui/CustomDialog'
export {default as MuiInlineCard} from './mui/cards/InlineCard'
export {default as MuiListCard} from './mui/cards/ListCard'
export {default as MuiTableCard} from './mui/cards/TableCard'
diff --git a/src/components/mui/AlertModal/index.js b/src/components/mui/AlertModal/index.js
index 0fcb0e17..a22d99f3 100644
--- a/src/components/mui/AlertModal/index.js
+++ b/src/components/mui/AlertModal/index.js
@@ -14,36 +14,19 @@
import React from "react";
import PropTypes from "prop-types";
import T from "i18n-react";
-import { Divider, IconButton, Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from "@mui/material";
-import CloseIcon from "@mui/icons-material/Close";
+import { DialogContentText } from "@mui/material";
+import CustomDialog from "../CustomDialog";
const AlertModal = ({ title, message, open, onClose }) => {
return (
-
+
+ {message}
+
);
};
diff --git a/src/components/mui/CustomDialog/index.js b/src/components/mui/CustomDialog/index.js
new file mode 100644
index 00000000..32b235a5
--- /dev/null
+++ b/src/components/mui/CustomDialog/index.js
@@ -0,0 +1,153 @@
+/**
+ * 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, { useEffect, useRef, useState } from "react";
+import PropTypes from "prop-types";
+import {
+ Button,
+ CircularProgress,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ Divider,
+ IconButton
+} from "@mui/material";
+import CloseIcon from "@mui/icons-material/Close";
+
+const CustomDialog = ({
+ title,
+ open,
+ onClose,
+ maxWidth,
+ fullWidth,
+ primaryAction,
+ secondaryAction,
+ children
+}) => {
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ // the only purpose of this is to avoid having a console log when modal is closed and setIsSubmitting is called
+ const mountedRef = useRef(true);
+
+ useEffect(() => {
+ mountedRef.current = true;
+ return () => {
+ mountedRef.current = false;
+ };
+ }, []);
+
+ const runAction = (action) => {
+ if (isSubmitting) return;
+
+ const result = action.onClick();
+
+ // if action is async then guard double click
+ if (result && typeof result.then === "function") {
+ setIsSubmitting(true);
+ Promise.resolve(result)
+ .catch(() => {})
+ .finally(() => {
+ if (mountedRef.current) setIsSubmitting(false);
+ });
+ }
+ };
+
+ const handlePrimaryClick = () => runAction(primaryAction);
+ const handleSecondaryClick = () => runAction(secondaryAction);
+
+ const handleClose = () => {
+ if (isSubmitting) return;
+ onClose();
+ };
+
+ return (
+
+ );
+};
+
+const actionPropType = PropTypes.shape({
+ label: PropTypes.node.isRequired,
+ onClick: PropTypes.func.isRequired,
+ disabled: PropTypes.bool
+});
+
+CustomDialog.propTypes = {
+ title: PropTypes.node.isRequired,
+ open: PropTypes.bool.isRequired,
+ onClose: PropTypes.func.isRequired,
+ maxWidth: PropTypes.oneOf(["xs", "sm", "md", "lg", "xl", false]),
+ fullWidth: PropTypes.bool,
+ primaryAction: actionPropType,
+ secondaryAction: actionPropType,
+ children: PropTypes.node.isRequired
+};
+
+CustomDialog.defaultProps = {
+ maxWidth: "sm",
+ fullWidth: true,
+ primaryAction: null,
+ secondaryAction: null
+};
+
+export default CustomDialog;
diff --git a/src/components/mui/ItemSettingsModal/index.js b/src/components/mui/ItemSettingsModal/index.js
index 77ab82d5..917afb75 100644
--- a/src/components/mui/ItemSettingsModal/index.js
+++ b/src/components/mui/ItemSettingsModal/index.js
@@ -15,15 +15,9 @@ import React from "react";
import PropTypes from "prop-types";
import T from "i18n-react/dist/i18n-react";
import Box from "@mui/material/Box";
-import Button from "@mui/material/Button";
-import Dialog from "@mui/material/Dialog";
-import DialogActions from "@mui/material/DialogActions";
-import DialogContent from "@mui/material/DialogContent";
-import DialogTitle from "@mui/material/DialogTitle";
import Divider from "@mui/material/Divider";
-import IconButton from "@mui/material/IconButton";
import Typography from "@mui/material/Typography";
-import CloseIcon from "@mui/icons-material/Close";
+import CustomDialog from "../CustomDialog";
import ItemTableField from "../FormItemTable/components/ItemTableField";
const ItemSettingsModal = ({ item, timeZone, open, onClose }) => {
@@ -35,53 +29,36 @@ const ItemSettingsModal = ({ item, timeZone, open, onClose }) => {
};
return (
-
+
+
+ {item?.name}
+
+
+ {itemFields.map((exc) => (
+
+
+
+ ))}
+
);
};
diff --git a/src/components/mui/NotesModal/index.js b/src/components/mui/NotesModal/index.js
index 64ddfbcb..5f347286 100644
--- a/src/components/mui/NotesModal/index.js
+++ b/src/components/mui/NotesModal/index.js
@@ -15,8 +15,8 @@ import React, { useState, useEffect } from "react";
import PropTypes from "prop-types";
import T from "i18n-react";
import { useField } from "formik";
-import { Button, Dialog, DialogActions, DialogContent, Divider, DialogContentText, DialogTitle, IconButton, TextField } from "@mui/material";
-import CloseIcon from "@mui/icons-material/Close";
+import { DialogContentText, TextField } from "@mui/material";
+import CustomDialog from "../CustomDialog";
const NotesModal = ({ id, label, open, title, placeholder, onClose }) => {
const name = `i-${id}-c-global-f-notes`;
@@ -34,40 +34,27 @@ const NotesModal = ({ id, label, open, title, placeholder, onClose }) => {
};
return (
-
+
+ {label}
+ setNotes(ev.target.value)}
+ value={notes}
+ margin="normal"
+ multiline
+ fullWidth
+ rows={4}
+ placeholder={placeholder || T.translate("placeholders.notes")}
+ />
+
);
};
diff --git a/src/components/mui/UploadDialog/index.js b/src/components/mui/UploadDialog/index.js
index 65b5035f..4a233238 100644
--- a/src/components/mui/UploadDialog/index.js
+++ b/src/components/mui/UploadDialog/index.js
@@ -12,20 +12,11 @@
* */
import React, { useState } from "react";
-import {
- Button,
- Dialog,
- DialogContent,
- DialogTitle,
- Divider,
- IconButton,
- Typography,
- DialogActions
-} from "@mui/material";
+import { Divider, Typography } from "@mui/material";
import PropTypes from "prop-types";
import UploadInputV3 from "../../inputs/upload-input-v3";
import T from "i18n-react";
-import CloseIcon from "@mui/icons-material/Close";
+import CustomDialog from "../CustomDialog";
import {
DECIMAL_DIGITS
} from "../../../utils/constants";
@@ -57,11 +48,10 @@ const UploadDialog = ({
onClose();
};
- const handleUpload = () => {
+ const handleUpload = () =>
onUpload(uploadedFile).then(() => {
handleClose();
});
- };
const handleRemove = () => {
setUploadedFile(null);
@@ -79,54 +69,37 @@ const UploadDialog = ({
: [];
return (
-
+
+
+ {fileMeta.name}
+
+
+ {fileMeta.description}
+
+
+
+
);
};
diff --git a/src/components/mui/__tests__/custom-dialog.test.js b/src/components/mui/__tests__/custom-dialog.test.js
new file mode 100644
index 00000000..2d083636
--- /dev/null
+++ b/src/components/mui/__tests__/custom-dialog.test.js
@@ -0,0 +1,154 @@
+/**
+ * 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.
+ * */
+
+jest.mock("i18n-react/dist/i18n-react", () => ({
+ __esModule: true,
+ default: { translate: (key) => key }
+}));
+
+import React from "react";
+import { render, screen, fireEvent, act } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import "@testing-library/jest-dom";
+import CustomDialog from "../CustomDialog/index";
+
+const deferred = () => {
+ let resolve;
+ let reject;
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return { promise, resolve, reject };
+};
+
+const defaultProps = {
+ title: "Dialog Title",
+ open: true,
+ onClose: jest.fn()
+};
+
+const getPrimaryButton = () =>
+ screen.getByRole("button", { name: "Primary" });
+
+const getCloseButton = () => screen.getByRole("button", { name: /close/i });
+
+beforeEach(() => jest.clearAllMocks());
+
+describe("CustomDialog", () => {
+ test("a pending onClick promise disables the primary button and the close icon, and ignores backdrop/Escape", async () => {
+ const { promise, resolve } = deferred();
+ const onClick = jest.fn(() => promise);
+
+ render(
+
+ content
+
+ );
+
+ await userEvent.click(getPrimaryButton());
+
+ expect(getPrimaryButton()).toBeDisabled();
+ expect(getCloseButton()).toBeDisabled();
+
+ fireEvent.keyDown(screen.getByRole("dialog"), {
+ key: "Escape",
+ code: "Escape"
+ });
+ expect(defaultProps.onClose).not.toHaveBeenCalled();
+
+ fireEvent.click(document.querySelector(".MuiBackdrop-root"));
+ expect(defaultProps.onClose).not.toHaveBeenCalled();
+
+ await act(async () => {
+ resolve();
+ await promise;
+ });
+ });
+
+ test("a resolved onClick re-enables the primary button and the close icon", async () => {
+ const { promise, resolve } = deferred();
+ const onClick = jest.fn(() => promise);
+
+ render(
+
+ content
+
+ );
+
+ await userEvent.click(getPrimaryButton());
+ expect(getPrimaryButton()).toBeDisabled();
+
+ await act(async () => {
+ resolve();
+ await promise;
+ });
+
+ expect(getPrimaryButton()).toBeEnabled();
+ expect(getCloseButton()).toBeEnabled();
+ });
+
+ test("a rejected onClick re-enables the primary button and the close icon", async () => {
+ const { promise, reject } = deferred();
+ const onClick = jest.fn(() => promise);
+
+ render(
+
+ content
+
+ );
+
+ await userEvent.click(getPrimaryButton());
+ expect(getPrimaryButton()).toBeDisabled();
+
+ await act(async () => {
+ reject(new Error("save failed"));
+ await promise.catch(() => {});
+ });
+
+ expect(getPrimaryButton()).toBeEnabled();
+ expect(getCloseButton()).toBeEnabled();
+ });
+
+ test("a sync onClick never enters the locked state", async () => {
+ const onClick = jest.fn();
+
+ render(
+
+ content
+
+ );
+
+ await userEvent.click(getPrimaryButton());
+
+ expect(onClick).toHaveBeenCalledTimes(1);
+ expect(getPrimaryButton()).toBeEnabled();
+ expect(getCloseButton()).toBeEnabled();
+ });
+
+ test("renders DialogActions as a direct child of the dialog paper", () => {
+ render(
+
+ content
+
+ );
+
+ const paper = document.querySelector(".MuiDialog-paper");
+ const actions = document.querySelector(".MuiDialogActions-root");
+ expect(actions).toBeInTheDocument();
+ expect(actions.parentElement).toBe(paper);
+ });
+});
diff --git a/webpack.common.js b/webpack.common.js
index 5719e74b..043bd518 100644
--- a/webpack.common.js
+++ b/webpack.common.js
@@ -145,6 +145,7 @@ module.exports = {
'components/mui/auth-button': './src/components/mui/AuthButton/index.js',
'components/mui/cart-button': './src/components/mui/CartButton/index.js',
'components/mui/confirm-delete-dialog': './src/components/mui/ConfirmDeleteDialog/index.js',
+ 'components/mui/custom-dialog': './src/components/mui/CustomDialog/index.js',
'components/mui/cards': './src/components/mui/cards/index.js',
'components/mui/cards/inline-card': './src/components/mui/cards/InlineCard/index.js',
'components/mui/cards/list-card': './src/components/mui/cards/ListCard/index.js',