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 ( - - {title} - ({ - position: "absolute", - right: 8, - top: 8, - color: theme.palette.grey[500] - })} - > - - - - - {message} - - - - - - + + {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 ( + + {title} + ({ + position: "absolute", + right: 12, + top: 12, + color: theme.palette.grey[500] + })} + > + + + + {children} + {(primaryAction || secondaryAction) && ( + + {secondaryAction && ( + + )} + {primaryAction && ( + + )} + + )} + + ); +}; + +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 ( - - {T.translate("general.settings")} - ({ - position: "absolute", - right: 8, - top: 8, - color: theme.palette.grey[500] - })} - > - - - - - - {item?.name} - - - {itemFields.map((exc) => ( - - - - ))} - - - - - + + + {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 ( - - {title || T.translate("general.notes")} - ({ - position: "absolute", - right: 8, - top: 8, - color: theme.palette.grey[500] - })} - > - - - - - {label} - setNotes(ev.target.value)} - value={notes} - margin="normal" - multiline - fullWidth - rows={4} - placeholder={placeholder || T.translate("placeholders.notes")} - /> - - - - - + + {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 ( - - {T.translate("upload_input.upload_file")} - ({ - position: "absolute", - right: 8, - top: 8, - color: theme.palette.grey[500] - })} - > - - - - - - {fileMeta.name} - - - {fileMeta.description} - - - - - - - - + + + {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',