diff --git a/package.json b/package.json index 653c2a42..839b3ea5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openstack-uicore-foundation", - "version": "5.0.6", + "version": "5.0.7-beta.3", "description": "ui reactjs components for openstack marketing site", "main": "lib/openstack-uicore-foundation.js", "scripts": { @@ -26,6 +26,10 @@ "@mui/material": "^6.4.3", "@mui/x-date-pickers": "^7.26.0", "@react-pdf/renderer": "^3.1.11", + "@sentry/react": "^8.54.0", + "@sentry/webpack-plugin": "^3.1.2", + "@stripe/react-stripe-js": "^5.4.1", + "@stripe/stripe-js": "^8.5.3", "@testing-library/jest-dom": "5.17.0", "@testing-library/react": "12.1.5", "@testing-library/user-event": "14.5.2", @@ -114,6 +118,9 @@ "@mui/material": "^6.4.3", "@mui/x-date-pickers": "^7.26.0", "@react-pdf/renderer": "^3.1.11", + "@sentry/react": "^8.54.0", + "@stripe/react-stripe-js": "^5.4.1", + "@stripe/stripe-js": "^8.5.3", "awesome-bootstrap-checkbox": "^1.0.1", "browser-tabs-lock": "^1.2.15", "crypto-js": "^4.1.1", @@ -131,6 +138,7 @@ "lodash": "^4.17.14", "moment": "^2.22.2", "moment-timezone": "^0.5.21", + "prop-types": "^15.8.1", "react": "^17.0.0", "react-beautiful-dnd": "^13.1.1", "react-bootstrap": "^0.31.5", diff --git a/src/components/index.js b/src/components/index.js index ea38031e..9bcf931b 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -64,7 +64,6 @@ 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 MuiCustomAlert} from './mui/custom-alert' -export {default as MuiDndList} from './mui/dnd-list' export {default as MuiDropdownCheckbox} from './mui/dropdown-checkbox' export {default as MuiMenuButton} from './mui/menu-button' export {default as MuiSearchInput} from './mui/search-input' @@ -78,10 +77,8 @@ export {default as MuiNotesModal} from './mui/NotesModal' export {default as MuiSnackbarNotification} from './mui/SnackbarNotification' export {default as MuiInfiniteTable} from './mui/infinite-table' export {default as MuiEditableTable} from './mui/editable-table/mui-table-editable' -export {default as MuiSortableTable} from './mui/sortable-table/mui-table-sortable' export {default as MuiTable} from './mui/table/mui-table' -export {default as MuiAdditionalInput} from './mui/formik-inputs/additional-input/additional-input' -export {default as MuiAdditionalInputList} from './mui/formik-inputs/additional-input/additional-input-list' +export {TotalRow as MuiTotalRow, NotesRow as MuiNotesRow} from './mui/table/extra-rows' export {default as MuiFormikAsyncSelect} from './mui/formik-inputs/mui-formik-async-select' export {default as MuiFormikCheckboxGroup} from './mui/formik-inputs/mui-formik-checkbox-group' export {default as MuiFormikCheckbox} from './mui/formik-inputs/mui-formik-checkbox' @@ -105,13 +102,31 @@ export {default as MuiItemPriceTiers} from './mui/formik-inputs/item-price-tiers export {default as MuiSponsorInput} from './mui/formik-inputs/mui-sponsor-input' export {default as MuiSponsorshipInput} from './mui/formik-inputs/sponsorship-input-mui' export {default as MuiSponsorshipSummitSelect} from './mui/formik-inputs/sponsorship-summit-select-mui' +export {default as MuiAlertButton} from './mui/AlertButton' +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 MuiDashboardCard} from './mui/DashboardCard' +export {default as MuiDownloadBtn} from './mui/DownloadBtn' +export {default as MuiLoadingOverlay} from './mui/LoadingOverlay' +export {default as MuiNavBar} from './mui/NavBar' +export {default as MuiOrderSummary} from './mui/OrderSummary' +export {default as MuiStatusChip} from './mui/StatusChip' +export {default as MuiUploadBtn} from './mui/UploadBtn' +export {default as MuiUploadDialog} from './mui/UploadDialog' -// this 5 includes 3rd party deps +// these include 3rd party deps // export {default as ExtraQuestionsForm } from './extra-questions/index.js'; // export {default as GMap} from './google-map'; // export {default as TextEditorV2} from './inputs/editor-input-v2' // export {default as TextEditorV3} from './inputs/editor-input-v3' // export {default as CompanyInputV2} from './inputs/company-input-v2.js' +// export {default as MuiDndList} from './mui/dnd-list' // react-beautiful-dnd +// export {default as MuiSortableTable} from './mui/sortable-table/mui-table-sortable' // react-beautiful-dnd +// export {default as MuiStripePayment} from './mui/StripePayment' // @stripe/react-stripe-js, @stripe/stripe-js +// export {default as MuiAdditionalInput} from './mui/formik-inputs/additional-input/additional-input' // react-beautiful-dnd (via dnd-list) +// export {default as MuiAdditionalInputList} from './mui/formik-inputs/additional-input/additional-input-list' // react-beautiful-dnd (via dnd-list) let language = getCurrentUserLanguage(); diff --git a/src/components/mui/AlertButton/index.js b/src/components/mui/AlertButton/index.js new file mode 100644 index 00000000..7b544d12 --- /dev/null +++ b/src/components/mui/AlertButton/index.js @@ -0,0 +1,31 @@ +/** + * 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 { Button } from "@mui/material"; +import NotificationsIcon from "@mui/icons-material/Notifications"; + +const AlertButton = ({ label, onClick }) => ( + + ); + +export default AlertButton; diff --git a/src/components/mui/AlertModal/index.js b/src/components/mui/AlertModal/index.js new file mode 100644 index 00000000..0fcb0e17 --- /dev/null +++ b/src/components/mui/AlertModal/index.js @@ -0,0 +1,57 @@ +/** + * 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 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"; + +const AlertModal = ({ title, message, open, onClose }) => { + return ( + + {title} + ({ + position: "absolute", + right: 8, + top: 8, + color: theme.palette.grey[500] + })} + > + + + + + {message} + + + + + + + ); +}; + +AlertModal.propTypes = { + title: PropTypes.string.isRequired, + message: PropTypes.string.isRequired, + open: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired +}; + +export default AlertModal; diff --git a/src/components/mui/AuthButton/index.js b/src/components/mui/AuthButton/index.js new file mode 100644 index 00000000..72908a41 --- /dev/null +++ b/src/components/mui/AuthButton/index.js @@ -0,0 +1,66 @@ +/** + * 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 } from "react"; +import T from "i18n-react"; +import {Button, Box} from '@mui/material'; +import styles from "./styles.module.scss" + +const AuthButton = ({ isLoggedUser, doLogin, initLogOut, picture }) => { + const [showLogOut, setShowLogOut] = useState(false); + + const toggleLogOut = () => { + setShowLogOut(!showLogOut); + }; + + if (isLoggedUser) { + return ( +
+
+ {showLogOut && ( + + )} +
+ ); + } + return ( + + + + ); + +}; + +export default AuthButton; diff --git a/src/components/mui/AuthButton/styles.module.scss b/src/components/mui/AuthButton/styles.module.scss new file mode 100644 index 00000000..d3074ae3 --- /dev/null +++ b/src/components/mui/AuthButton/styles.module.scss @@ -0,0 +1,28 @@ +.userMenu { + position: absolute; + top: 12px; + right: 34px; + height: 40px; + width: 140px; + cursor: pointer; +} + +.login { + width: 100%; + text-align: right; +} + +.logout { + top: 4px; + right: 4px; +} + +.profilePic { + height: 40px; + width: 40px; + border: 1px solid #afafaf; + border-radius: 20px; + overflow: hidden; + float: right; + background-size: cover; +} \ No newline at end of file diff --git a/src/components/mui/CartButton/index.js b/src/components/mui/CartButton/index.js new file mode 100644 index 00000000..fd033ee0 --- /dev/null +++ b/src/components/mui/CartButton/index.js @@ -0,0 +1,53 @@ +/** + * 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 { Button, Box } from "@mui/material"; +import T from "i18n-react"; + +const CartButton = ({ itemCount, onClick, sx, disabled }) => { + return ( + + ); +}; + +export default CartButton; diff --git a/src/components/mui/ConfirmDeleteDialog/index.js b/src/components/mui/ConfirmDeleteDialog/index.js new file mode 100644 index 00000000..451ba1f0 --- /dev/null +++ b/src/components/mui/ConfirmDeleteDialog/index.js @@ -0,0 +1,58 @@ +/** + * 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 PropTypes from "prop-types"; +import {Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle} from "@mui/material"; +import T from "i18n-react"; + +const ConfirmDeleteDialog = ({ open, onClose, onConfirm, message }) => { + return ( + + + {T.translate("alerts.confirm_delete_title")} + + + + {message || T.translate("alerts.confirm_delete")} + + + + + + + + ); +}; + +ConfirmDeleteDialog.propTypes = { + open: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onConfirm: PropTypes.func.isRequired, + message: PropTypes.string +}; + +ConfirmDeleteDialog.defaultProps = { + message: "" +}; + +export default ConfirmDeleteDialog; diff --git a/src/components/mui/CustomAlert/index.js b/src/components/mui/CustomAlert/index.js new file mode 100644 index 00000000..24543f01 --- /dev/null +++ b/src/components/mui/CustomAlert/index.js @@ -0,0 +1,34 @@ +/** + * 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 { Alert } from "@mui/material"; + +const CustomAlert = ({ severity = "info", message = "", hideIcon = false }) => ( + +
+ +); + +export default CustomAlert; diff --git a/src/components/mui/DashboardCard/index.js b/src/components/mui/DashboardCard/index.js new file mode 100644 index 00000000..c551a149 --- /dev/null +++ b/src/components/mui/DashboardCard/index.js @@ -0,0 +1,104 @@ +/** + * 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 { + Card, + CardContent, + Divider, + List, + ListItem, + Typography +} from "@mui/material"; + +const Heading = ({ children }) => ( + + {children} + +); + +const Value = ({ children }) => ( + + {children} + +); + +const DashboardCard = ({ title, rows, columns }) => { + const renderList = () => + rows.map((row, i) => ( + // eslint-disable-next-line react/no-array-index-key + + + {row.label} + {row.value} + + {i < rows.length - 1 && } + + )); + + const renderTable = () => { + const header = ( + + + {columns.map((col, i) => ( + // eslint-disable-next-line react/no-array-index-key + {col.label} + ))} + + + + ); + + const rest = rows.map((row, i) => ( + // eslint-disable-next-line react/no-array-index-key + + + {columns.map((col, j) => ( + // eslint-disable-next-line react/no-array-index-key + {row[col.key]} + ))} + + {i < rows.length - 1 && } + + )); + + return [header, ...rest]; + }; + + return ( + + + + {title} + + + {!columns && renderList()} + {columns && renderTable()} + + + + ); +}; + +export default DashboardCard; diff --git a/src/components/mui/DownloadBtn/index.js b/src/components/mui/DownloadBtn/index.js new file mode 100644 index 00000000..f762bfb9 --- /dev/null +++ b/src/components/mui/DownloadBtn/index.js @@ -0,0 +1,35 @@ +/** + * 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 T from "i18n-react"; +import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; +import { Button } from "@mui/material"; + +const DownloadBtn = ({ url }) => { + return ( + + ); +}; + +export default DownloadBtn; diff --git a/src/components/mui/LoadingOverlay/index.jsx b/src/components/mui/LoadingOverlay/index.jsx new file mode 100644 index 00000000..f60eb3b3 --- /dev/null +++ b/src/components/mui/LoadingOverlay/index.jsx @@ -0,0 +1,27 @@ +/** + * 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 Backdrop from "@mui/material/Backdrop"; +import CircularProgress from "@mui/material/CircularProgress"; + +const LoadingOverlay = ({ loading }) => ( + ({ color: "#fff", zIndex: theme.zIndex.drawer + 1 })} + open={loading} + > + + + ); + +export default LoadingOverlay; diff --git a/src/components/mui/NavBar/index.js b/src/components/mui/NavBar/index.js new file mode 100644 index 00000000..abce313a --- /dev/null +++ b/src/components/mui/NavBar/index.js @@ -0,0 +1,49 @@ +/** + * 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 {AppBar, Box, Toolbar, Typography} from "@mui/material"; +import AuthButton from "../AuthButton"; +import styles from "./styles.module.scss"; + +const NavBar = ({title, profilePic, isLoggedUser, onClickLogin, initLogOut}) => { + return ( + + + + {title} + + + + + + + ); +}; + +export default NavBar; diff --git a/src/components/mui/NavBar/styles.module.scss b/src/components/mui/NavBar/styles.module.scss new file mode 100644 index 00000000..c291140c --- /dev/null +++ b/src/components/mui/NavBar/styles.module.scss @@ -0,0 +1,12 @@ +.wrapper { + height: 64px; + border-bottom: 1px solid #b3b3b3; + background-color: white; +} + +.title { + font-size: 16px; + text-align: center; + height: 100%; + line-height: 60px; +} \ No newline at end of file diff --git a/src/components/mui/NotesModal/index.js b/src/components/mui/NotesModal/index.js index 150cf6dd..64ddfbcb 100644 --- a/src/components/mui/NotesModal/index.js +++ b/src/components/mui/NotesModal/index.js @@ -11,28 +11,22 @@ * limitations under the License. * */ -import React, { useEffect, useState } from "react"; -import T from "i18n-react/dist/i18n-react"; +import React, { useState, useEffect } from "react"; import PropTypes from "prop-types"; -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 DialogContentText from "@mui/material/DialogContentText"; -import DialogTitle from "@mui/material/DialogTitle"; +import T from "i18n-react"; import { useField } from "formik"; -import { Divider, IconButton, TextField } from "@mui/material"; +import { Button, Dialog, DialogActions, DialogContent, Divider, DialogContentText, DialogTitle, IconButton, TextField } from "@mui/material"; import CloseIcon from "@mui/icons-material/Close"; -const NotesModal = ({ item, open, onClose }) => { - const name = `i-${item?.form_item_id}-c-global-f-notes`; +const NotesModal = ({ id, label, open, title, placeholder, onClose }) => { + const name = `i-${id}-c-global-f-notes`; // eslint-disable-next-line const [field, meta, helpers] = useField(name); - const [notes, setNotes] = useState(""); + const [notes, setNotes] = useState(field?.value || ""); useEffect(() => { - setNotes(field.value || ""); - }, [field?.value]); + setNotes(field?.value || ""); + }, [id, field?.value]); const handleSave = () => { helpers.setValue(notes); @@ -41,9 +35,7 @@ const NotesModal = ({ item, open, onClose }) => { return ( - - {T.translate("sponsor_edit_form.notes")} - + {title || T.translate("general.notes")} { - {item?.name} + {label} setNotes(ev.target.value)} @@ -67,9 +59,7 @@ const NotesModal = ({ item, open, onClose }) => { multiline fullWidth rows={4} - placeholder={T.translate( - "sponsor_edit_form.notes_placeholder" - )} + placeholder={placeholder || T.translate("placeholders.notes")} /> @@ -82,7 +72,10 @@ const NotesModal = ({ item, open, onClose }) => { }; NotesModal.propTypes = { - item: PropTypes.object, + id: PropTypes.any, + label: PropTypes.string, + title: PropTypes.string, + placeholder: PropTypes.string, open: PropTypes.bool.isRequired, onClose: PropTypes.func.isRequired }; diff --git a/src/components/mui/OrderSummary/index.jsx b/src/components/mui/OrderSummary/index.jsx new file mode 100644 index 00000000..921e605d --- /dev/null +++ b/src/components/mui/OrderSummary/index.jsx @@ -0,0 +1,78 @@ +/** + * 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 { Box, CardContent, Skeleton, Typography } from "@mui/material"; +import T from "i18n-react"; +import { currencyAmountFromCents } from "../../../utils/money"; +import PropTypes from "prop-types"; + +const OrderSummary = ({ amount, dueDate, toName, fromName }) => { + const getPrice = () => { + if (amount != null) return currencyAmountFromCents(amount); + return ; + }; + + return ( + + + + + {getPrice()} + + {dueDate && ( + + {T.translate("general.due")} {dueDate} + + )} + + + + + + + {T.translate("general.to")} + + {toName} + + + + {T.translate("general.from")} + + {fromName} + + + + ); +}; + +OrderSummary.propTypes = { + amount: PropTypes.number.isRequired, + dueDate: PropTypes.string.isRequired, + toName: PropTypes.string.isRequired, + fromName: PropTypes.string.isRequired +}; + +export default OrderSummary; diff --git a/src/components/mui/StatusChip/index.js b/src/components/mui/StatusChip/index.js new file mode 100644 index 00000000..eee297ec --- /dev/null +++ b/src/components/mui/StatusChip/index.js @@ -0,0 +1,35 @@ +/** + * 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 { Chip } from "@mui/material"; +import PropTypes from "prop-types"; +import { FILE_UPLOAD_STATUS_COLOR } from "../../../utils/constants"; + +const StatusChip = ({ status }) => { + const color = FILE_UPLOAD_STATUS_COLOR[status] || "default"; + return ( + + ); +}; + +StatusChip.propTypes = { + status: PropTypes.string.isRequired +}; + +export default StatusChip; diff --git a/src/components/mui/StripePayment/__tests__/stripe-form.test.jsx b/src/components/mui/StripePayment/__tests__/stripe-form.test.jsx new file mode 100644 index 00000000..d3419131 --- /dev/null +++ b/src/components/mui/StripePayment/__tests__/stripe-form.test.jsx @@ -0,0 +1,162 @@ +import React from "react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import StripeForm from "../stripe-form"; +import { handleSentryException } from "../helpers"; + +jest.mock("i18n-react", () => ({ + __esModule: true, + default: { translate: (key) => key } +})); + +jest.mock("../helpers", () => ({ + handleSentryException: jest.fn() +})); + +jest.mock("../../../../utils/money", () => ({ + currencyAmountFromCents: (amount) => `$${amount}` +})); + +const mockConfirmPayment = jest.fn(); +const mockOn = jest.fn(); +const mockOff = jest.fn(); + +jest.mock("@stripe/react-stripe-js", () => ({ + PaymentElement: () =>
, + useStripe: () => ({ confirmPayment: mockConfirmPayment }), + useElements: () => ({ + getElement: () => ({ on: mockOn, off: mockOff }) + }) +})); + +const defaultProps = { + amount: 5000, + client: { + first_name: "Jane", + last_name: "Doe", + email: "jane@example.com", + address: {} + }, + redirectUrl: "/dashboard", + onSuccess: jest.fn(), + onError: jest.fn(), + onPaymentMethodChange: jest.fn() +}; + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe("StripeForm payment button state", () => { + it("stays disabled after successful payment", async () => { + const onSuccess = jest.fn().mockResolvedValue(undefined); + mockConfirmPayment.mockResolvedValue({ + paymentIntent: { id: "pi_123", status: "succeeded" } + }); + + render(); + + const button = screen.getByRole("button"); + fireEvent.click(button); + + await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1)); + + expect(button).toBeDisabled(); + }); + + it("re-enables on payment error so the user can retry", async () => { + mockConfirmPayment.mockResolvedValue({ + error: { message: "Your card was declined." } + }); + + render(); + + const button = screen.getByRole("button"); + fireEvent.click(button); + + await waitFor(() => + expect(defaultProps.onError).toHaveBeenCalledWith( + "Your card was declined." + ) + ); + + expect(button).not.toBeDisabled(); + }); + + it("keeps disabled, reports to Sentry, and calls onError if onSuccess throws", async () => { + const backendError = new Error("Backend error"); + const onSuccess = jest.fn().mockRejectedValue(backendError); + mockConfirmPayment.mockResolvedValue({ + paymentIntent: { id: "pi_456", status: "succeeded" } + }); + + render(); + + const button = screen.getByRole("button"); + fireEvent.click(button); + + await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1)); + + expect(handleSentryException).toHaveBeenCalledWith(backendError); + expect(defaultProps.onError).toHaveBeenCalledWith( + "stripe_form.payment_confirmation_error" + ); + expect(button).toBeDisabled(); + }); + + it("re-enables button for retryable status 'requires_action'", async () => { + mockConfirmPayment.mockResolvedValue({ + paymentIntent: { id: "pi_999", status: "requires_action" } + }); + + render(); + + const button = screen.getByRole("button"); + fireEvent.click(button); + + await waitFor(() => expect(button).not.toBeDisabled()); + expect(defaultProps.onSuccess).not.toHaveBeenCalled(); + expect(defaultProps.onError).not.toHaveBeenCalled(); + }); + + it("keeps button disabled for async 'processing' status", async () => { + mockConfirmPayment.mockResolvedValue({ + paymentIntent: { id: "pi_999", status: "processing" } + }); + + render(); + + const button = screen.getByRole("button"); + fireEvent.click(button); + + await waitFor(() => expect(mockConfirmPayment).toHaveBeenCalled()); + expect(button).toBeDisabled(); + expect(defaultProps.onSuccess).not.toHaveBeenCalled(); + expect(defaultProps.onError).not.toHaveBeenCalled(); + }); + + it("blocks a second submit while confirmPayment is still in flight", async () => { + const onSuccess = jest.fn().mockResolvedValue(undefined); + let resolveConfirmPayment; + mockConfirmPayment.mockImplementation( + () => + new Promise((resolve) => { + resolveConfirmPayment = resolve; + }) + ); + + render(); + + const button = screen.getByRole("button"); + const form = button.closest("form"); + fireEvent.submit(form); + fireEvent.submit(form); + + expect(mockConfirmPayment).toHaveBeenCalledTimes(1); + + resolveConfirmPayment({ + paymentIntent: { id: "pi_789", status: "succeeded" } + }); + await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1)); + }); +}); diff --git a/src/components/mui/StripePayment/__tests__/stripe-payment.test.jsx b/src/components/mui/StripePayment/__tests__/stripe-payment.test.jsx new file mode 100644 index 00000000..12ed3dca --- /dev/null +++ b/src/components/mui/StripePayment/__tests__/stripe-payment.test.jsx @@ -0,0 +1,84 @@ +/** + * 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("@stripe/stripe-js", () => ({ + loadStripe: jest.fn(() => Promise.resolve({})) +})); + +jest.mock("@stripe/react-stripe-js", () => ({ + Elements: ({ children }) =>
{children}
+})); + +jest.mock("../stripe-form", () => () =>
); + +import React from "react"; +import { render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import StripePayment from "../index"; + +const paymentProfile = { + test_mode_enabled: true, + test_publishable_key: "pk_test_123", + live_publishable_key: "pk_live_456" +}; + +const paymentIntent = { + client_secret: "cs_test_abc", + total_amount: 5000 +}; + +const defaultProps = { + paymentProfile, + paymentIntent, + client: { first_name: "Jane", last_name: "Doe", email: "jane@example.com", address: {} }, + redirectUrl: "/success", + onPaymentSuccess: jest.fn(), + onPaymentError: jest.fn(), + updatePaymentIntent: jest.fn() +}; + +describe("StripePayment", () => { + test("returns null when paymentProfile is not provided", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + test("returns null when paymentIntent is not provided", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + test("renders Elements and StripeForm when both props are provided", () => { + render(); + expect(screen.getByTestId("stripe-elements")).toBeInTheDocument(); + expect(screen.getByTestId("stripe-form")).toBeInTheDocument(); + }); + + test("uses test key when test_mode_enabled is true", () => { + const { loadStripe } = require("@stripe/stripe-js"); + render(); + expect(loadStripe).toHaveBeenCalledWith("pk_test_123"); + }); + + test("uses live key when test_mode_enabled is false", () => { + const { loadStripe } = require("@stripe/stripe-js"); + loadStripe.mockClear(); + render( + + ); + expect(loadStripe).toHaveBeenCalledWith("pk_live_456"); + }); +}); diff --git a/src/components/mui/StripePayment/helpers.js b/src/components/mui/StripePayment/helpers.js new file mode 100644 index 00000000..a4d8983d --- /dev/null +++ b/src/components/mui/StripePayment/helpers.js @@ -0,0 +1,20 @@ +/** + * 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 * as Sentry from "@sentry/react"; +import {isSentryInitialized} from "../../../utils/methods"; + +export const handleSentryException = (err) => + isSentryInitialized() + ? Sentry.captureException(err) + : console.log("Error on registration: ", err); \ No newline at end of file diff --git a/src/components/mui/StripePayment/index.jsx b/src/components/mui/StripePayment/index.jsx new file mode 100644 index 00000000..af7235af --- /dev/null +++ b/src/components/mui/StripePayment/index.jsx @@ -0,0 +1,58 @@ +/** + * 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, { useMemo } from "react"; +import { loadStripe } from "@stripe/stripe-js"; +import { Elements } from "@stripe/react-stripe-js"; +import StripeForm from "./stripe-form"; + +const StripePayment = ({ + paymentIntent, + paymentProfile, + client, + redirectUrl, + stripeFormTitle, + onPaymentSuccess, + onPaymentError, + updatePaymentIntent +}) => { + const { test_mode_enabled, test_publishable_key, live_publishable_key } = paymentProfile || {}; + const providerKey = test_mode_enabled ? test_publishable_key : live_publishable_key; + const stripePromise = useMemo( + () => (providerKey ? loadStripe(providerKey) : null), + [providerKey] + ); + + if (!paymentProfile || !paymentIntent?.client_secret || !providerKey) return null; + + const options = { + clientSecret: paymentIntent.client_secret, + appearance: { theme: "stripe" } + }; + + return ( + + + + ); +}; + +export default StripePayment; diff --git a/src/components/mui/StripePayment/stripe-form.jsx b/src/components/mui/StripePayment/stripe-form.jsx new file mode 100644 index 00000000..63bc06ed --- /dev/null +++ b/src/components/mui/StripePayment/stripe-form.jsx @@ -0,0 +1,133 @@ +/** + * 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, useState} from "react"; +import {Box, Button, Typography} from "@mui/material"; +import T from "i18n-react"; +import {PaymentElement, useElements, useStripe} from "@stripe/react-stripe-js"; +import {currencyAmountFromCents} from "../../../utils/money"; +import {handleSentryException} from "./helpers"; + +const buildAddress = (userAddress = {}) => { + const address = {}; + // stripe payment payload requires data that's not an empty string + if (userAddress.locality) address.city = userAddress.locality; + if (userAddress.country) address.country = userAddress.country; + if (userAddress.address1) address.line1 = userAddress.address1; + if (userAddress.address2) address.line2 = userAddress.address2; + if (userAddress.postal_code) address.postal_code = userAddress.postal_code; + if (userAddress.region) address.state = userAddress.region; + + if (Object.keys(address).length > 0) return {address}; + return {}; +}; + +const StripeForm = ({ + title, + amount = 0, + client, + redirectUrl, + onSuccess, + onError, + onPaymentMethodChange +}) => { + const stripe = useStripe(); + const elements = useElements(); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (elements) { + const pe = elements.getElement("payment"); + const handlePaymentMethodChange = (event) => { + if (event.value.type) { + onPaymentMethodChange(event.value.type); + } + }; + + pe.on("change", handlePaymentMethodChange); + + return () => { + pe.off("change", handlePaymentMethodChange); + }; + } + }, [elements]); + + const handleSubmit = async (e) => { + e.preventDefault(); + + if (!stripe || !elements || loading) return; + + setLoading(true); + + const {error, paymentIntent} = await stripe.confirmPayment({ + elements, + confirmParams: { + return_url: `${window.location.origin}${redirectUrl}`, + payment_method_data: { + billing_details: { + name: `${client.first_name} ${client.last_name}`, + email: client.email, + ...buildAddress(client.address) + } + } + }, + redirect: "if_required" + }); + + if (error) { + onError?.(error.message); + setLoading(false); + } else if (paymentIntent?.status === "succeeded") { + try { + await onSuccess?.(paymentIntent); + } catch (err) { + handleSentryException(err); + onError?.(T.translate("stripe_form.payment_confirmation_error")); + // Do not call setLoading(false) — payment already succeeded, keep form locked + } + // On success, keep loading=true so the button stays disabled until navigation + } else if (paymentIntent?.status === "processing") { + // Payment is async and still in flight — keep the form locked + } else { + // Genuinely retryable statuses (e.g. requires_action) — re-enable submission + setLoading(false); + } + }; + + return ( + + {title && + + {title || T.translate("stripe_form.title")} + + } + + + + + ); +}; + +export default StripeForm; diff --git a/src/components/mui/UploadBtn/index.js b/src/components/mui/UploadBtn/index.js new file mode 100644 index 00000000..968a3a29 --- /dev/null +++ b/src/components/mui/UploadBtn/index.js @@ -0,0 +1,34 @@ +/** + * 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 ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; +import T from "i18n-react"; +import { Button } from "@mui/material"; + +const UploadBtn = ({ disabled, onClick }) => { + return ( + + ); +}; + +export default UploadBtn; diff --git a/src/components/mui/UploadDialog/index.js b/src/components/mui/UploadDialog/index.js new file mode 100644 index 00000000..65b5035f --- /dev/null +++ b/src/components/mui/UploadDialog/index.js @@ -0,0 +1,143 @@ +/** + * 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 } from "react"; +import { + Button, + Dialog, + DialogContent, + DialogTitle, + Divider, + IconButton, + Typography, + DialogActions +} 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 { + DECIMAL_DIGITS +} from "../../../utils/constants"; + +const UploadDialog = ({ + name, + value, + open, + fileMeta, + maxFiles = 1, + onClose, + onUpload +}) => { + const [uploadedFile, setUploadedFile] = useState(null); + + const mediaType = { + id: name, + ...(fileMeta.max_file_size + ? { max_size: fileMeta.max_file_size.toFixed(DECIMAL_DIGITS) } + : {}), + max_uploads_qty: maxFiles, + type: { + allowed_extensions: fileMeta.allowed_extensions.split(",") + } + }; + + const handleClose = () => { + setUploadedFile(null); + onClose(); + }; + + const handleUpload = () => { + onUpload(uploadedFile).then(() => { + handleClose(); + }); + }; + + const handleRemove = () => { + setUploadedFile(null); + }; + + const canAddMore = () => (value?.length || 0) < maxFiles; + + const getInputValue = () => + value?.length > 0 + ? value.map((file) => ({ + ...file, + filename: + file.file_name ?? file.filename ?? file.file_path ?? file.file_url + })) + : []; + + return ( + + {T.translate("upload_input.upload_file")} + ({ + position: "absolute", + right: 8, + top: 8, + color: theme.palette.grey[500] + })} + > + + + + + + {fileMeta.name} + + + {fileMeta.description} + + + + + + + + + ); +}; + +UploadDialog.propTypes = { + name: PropTypes.string.isRequired, + value: PropTypes.array.isRequired, + open: PropTypes.bool.isRequired, + fileMeta: PropTypes.object.isRequired, + maxFiles: PropTypes.number, + onClose: PropTypes.func.isRequired, + onUpload: PropTypes.func.isRequired +}; + +export default UploadDialog; diff --git a/src/components/mui/__tests__/alert-button.test.js b/src/components/mui/__tests__/alert-button.test.js new file mode 100644 index 00000000..a586c8bc --- /dev/null +++ b/src/components/mui/__tests__/alert-button.test.js @@ -0,0 +1,37 @@ +/** + * 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 } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import AlertButton from "../AlertButton/index"; + +describe("AlertButton", () => { + test("renders the label", () => { + render(); + expect(screen.getByText("Send Alert")).toBeInTheDocument(); + }); + + test("calls onClick when clicked", async () => { + const onClick = jest.fn(); + render(); + await userEvent.click(screen.getByRole("button")); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + test("renders a button element", () => { + render(); + expect(screen.getByRole("button")).toBeInTheDocument(); + }); +}); diff --git a/src/components/mui/__tests__/alert-modal.test.js b/src/components/mui/__tests__/alert-modal.test.js new file mode 100644 index 00000000..1949cb03 --- /dev/null +++ b/src/components/mui/__tests__/alert-modal.test.js @@ -0,0 +1,57 @@ +/** + * 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 } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import AlertModal from "../AlertModal/index"; + +const defaultProps = { + title: "Alert Title", + message: "Something happened", + open: true, + onClose: jest.fn() +}; + +beforeEach(() => jest.clearAllMocks()); + +describe("AlertModal", () => { + test("renders title and message when open", () => { + render(); + expect(screen.getByText("Alert Title")).toBeInTheDocument(); + expect(screen.getByText("Something happened")).toBeInTheDocument(); + }); + + test("does not show content when open is false", () => { + render(); + expect(screen.queryByText("Alert Title")).not.toBeInTheDocument(); + }); + + test("calls onClose when close icon button is clicked", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: /close/i })); + expect(defaultProps.onClose).toHaveBeenCalledTimes(1); + }); + + test("calls onClose when OK button is clicked", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: /general\.ok/i })); + expect(defaultProps.onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/mui/__tests__/auth-button.test.js b/src/components/mui/__tests__/auth-button.test.js new file mode 100644 index 00000000..8637674d --- /dev/null +++ b/src/components/mui/__tests__/auth-button.test.js @@ -0,0 +1,93 @@ +/** + * 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 } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import AuthButton from "../AuthButton/index"; + +beforeEach(() => jest.clearAllMocks()); + +describe("AuthButton", () => { + test("shows login button when user is not logged in", () => { + render( + + ); + expect(screen.getByRole("button", { name: /buttons\.log_in/i })).toBeInTheDocument(); + }); + + test("calls doLogin when login button is clicked", async () => { + const doLogin = jest.fn(); + render( + + ); + await userEvent.click(screen.getByRole("button", { name: /buttons\.log_in/i })); + expect(doLogin).toHaveBeenCalledTimes(1); + }); + + test("does not show login button when user is logged in", () => { + render( + + ); + expect(screen.queryByRole("button", { name: /buttons\.log_in/i })).not.toBeInTheDocument(); + }); + + test("shows sign out button after clicking user menu", async () => { + const { container } = render( + + ); + await userEvent.click(container.firstChild); + expect(screen.getByRole("button", { name: /buttons\.sign_out/i })).toBeInTheDocument(); + }); + + test("calls initLogOut when sign out button is clicked", async () => { + const initLogOut = jest.fn(); + const { container } = render( + + ); + await userEvent.click(container.firstChild); + await userEvent.click(screen.getByRole("button", { name: /buttons\.sign_out/i })); + expect(initLogOut).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/mui/__tests__/cart-button.test.js b/src/components/mui/__tests__/cart-button.test.js new file mode 100644 index 00000000..76581525 --- /dev/null +++ b/src/components/mui/__tests__/cart-button.test.js @@ -0,0 +1,54 @@ +/** + * 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 } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import CartButton from "../CartButton/index"; + +beforeEach(() => jest.clearAllMocks()); + +describe("CartButton", () => { + test("renders the button", () => { + render(); + expect(screen.getByRole("button")).toBeInTheDocument(); + }); + + test("shows item count when not disabled", () => { + render(); + expect(screen.getByText("5")).toBeInTheDocument(); + }); + + test("hides item count when disabled", () => { + render(); + expect(screen.queryByText("5")).not.toBeInTheDocument(); + }); + + test("calls onClick when clicked", async () => { + const onClick = jest.fn(); + render(); + await userEvent.click(screen.getByRole("button")); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + test("is disabled when disabled prop is true", () => { + render(); + expect(screen.getByRole("button")).toBeDisabled(); + }); +}); diff --git a/src/components/mui/__tests__/confirm-delete-dialog.test.js b/src/components/mui/__tests__/confirm-delete-dialog.test.js new file mode 100644 index 00000000..72940922 --- /dev/null +++ b/src/components/mui/__tests__/confirm-delete-dialog.test.js @@ -0,0 +1,62 @@ +/** + * 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 } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import ConfirmDeleteDialog from "../ConfirmDeleteDialog/index"; + +const defaultProps = { + open: true, + onClose: jest.fn(), + onConfirm: jest.fn(), + message: "Are you sure you want to delete this?" +}; + +beforeEach(() => jest.clearAllMocks()); + +describe("ConfirmDeleteDialog", () => { + test("renders title and message when open", () => { + render(); + expect(screen.getByText("alerts.confirm_delete_title")).toBeInTheDocument(); + expect(screen.getByText("Are you sure you want to delete this?")).toBeInTheDocument(); + }); + + test("does not render content when closed", () => { + render(); + expect(screen.queryByText("alerts.confirm_delete_title")).not.toBeInTheDocument(); + }); + + test("calls onClose when cancel button is clicked", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: /general\.cancel/i })); + expect(defaultProps.onClose).toHaveBeenCalledTimes(1); + }); + + test("calls onConfirm when delete button is clicked", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: /general\.delete/i })); + expect(defaultProps.onConfirm).toHaveBeenCalledTimes(1); + }); + + test("shows default message when no message prop provided", () => { + render(); + expect(screen.getByText("alerts.confirm_delete")).toBeInTheDocument(); + }); +}); diff --git a/src/components/mui/__tests__/custom-alert-component.test.js b/src/components/mui/__tests__/custom-alert-component.test.js new file mode 100644 index 00000000..e9ab6862 --- /dev/null +++ b/src/components/mui/__tests__/custom-alert-component.test.js @@ -0,0 +1,45 @@ +/** + * 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 } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import CustomAlert from "../CustomAlert/index"; + +describe("CustomAlert (component)", () => { + test("renders the message", () => { + render(); + expect(screen.getByTestId("custom-alert")).toBeInTheDocument(); + expect(screen.getByTestId("custom-alert").innerHTML).toBe("Hello world"); + }); + + test("renders without crashing when no props provided", () => { + const { container } = render(); + expect(container.firstChild).toBeInTheDocument(); + }); + + test("renders HTML message content safely via dangerouslySetInnerHTML", () => { + render(); + expect(screen.getByTestId("custom-alert").querySelector("strong")).toBeInTheDocument(); + }); + + test("renders with error severity", () => { + render(); + expect(screen.getByRole("alert")).toBeInTheDocument(); + }); + + test("renders with success severity", () => { + render(); + expect(screen.getByRole("alert")).toBeInTheDocument(); + }); +}); diff --git a/src/components/mui/__tests__/dashboard-card.test.js b/src/components/mui/__tests__/dashboard-card.test.js new file mode 100644 index 00000000..e58be55f --- /dev/null +++ b/src/components/mui/__tests__/dashboard-card.test.js @@ -0,0 +1,57 @@ +/** + * 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 } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import DashboardCard from "../DashboardCard/index"; + +describe("DashboardCard", () => { + test("renders the title", () => { + render(); + expect(screen.getByText("My Card")).toBeInTheDocument(); + }); + + test("renders rows in list mode", () => { + const rows = [ + { label: "Name", value: "Alice" }, + { label: "Role", value: "Admin" } + ]; + render(); + expect(screen.getByText("Name")).toBeInTheDocument(); + expect(screen.getByText("Alice")).toBeInTheDocument(); + expect(screen.getByText("Role")).toBeInTheDocument(); + expect(screen.getByText("Admin")).toBeInTheDocument(); + }); + + test("renders table mode with columns", () => { + const columns = [ + { key: "name", label: "Name" }, + { key: "score", label: "Score" } + ]; + const rows = [{ name: "Bob", score: "100" }]; + render(); + expect(screen.getByText("Name")).toBeInTheDocument(); + expect(screen.getByText("Score")).toBeInTheDocument(); + expect(screen.getByText("Bob")).toBeInTheDocument(); + expect(screen.getByText("100")).toBeInTheDocument(); + }); + + test("renders multiple rows in table mode", () => { + const columns = [{ key: "item", label: "Item" }]; + const rows = [{ item: "First" }, { item: "Second" }]; + render(); + expect(screen.getByText("First")).toBeInTheDocument(); + expect(screen.getByText("Second")).toBeInTheDocument(); + }); +}); diff --git a/src/components/mui/__tests__/download-btn.test.js b/src/components/mui/__tests__/download-btn.test.js new file mode 100644 index 00000000..8b370d03 --- /dev/null +++ b/src/components/mui/__tests__/download-btn.test.js @@ -0,0 +1,44 @@ +/** + * 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 } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import DownloadBtn from "../DownloadBtn/index"; + +describe("DownloadBtn", () => { + test("renders a link button", () => { + render(); + expect(screen.getByRole("link")).toBeInTheDocument(); + }); + + test("has the correct href", () => { + render(); + expect(screen.getByRole("link")).toHaveAttribute("href", "https://example.com/file.pdf"); + }); + + test("opens in a new tab", () => { + render(); + expect(screen.getByRole("link")).toHaveAttribute("target", "_blank"); + }); + + test("has rel noopener noreferrer", () => { + render(); + expect(screen.getByRole("link")).toHaveAttribute("rel", "noopener noreferrer"); + }); +}); diff --git a/src/components/mui/__tests__/loading-overlay.test.js b/src/components/mui/__tests__/loading-overlay.test.js new file mode 100644 index 00000000..84c2a432 --- /dev/null +++ b/src/components/mui/__tests__/loading-overlay.test.js @@ -0,0 +1,36 @@ +/** + * 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 } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import LoadingOverlay from "../LoadingOverlay/index"; + +describe("LoadingOverlay", () => { + test("renders without crashing when loading is true", () => { + const { container } = render(); + expect(container.firstChild).toBeInTheDocument(); + }); + + test("renders without crashing when loading is false", () => { + const { container } = render(); + expect(container.firstChild).toBeInTheDocument(); + }); + + test("backdrop is visible when loading is true", () => { + const { container } = render(); + // MUI Backdrop renders with aria-hidden=false when open + const backdrop = container.querySelector(".MuiBackdrop-root"); + expect(backdrop).toBeInTheDocument(); + }); +}); diff --git a/src/components/mui/__tests__/mui-table.test.js b/src/components/mui/__tests__/mui-table.test.js index b8a0920a..02a03bc9 100644 --- a/src/components/mui/__tests__/mui-table.test.js +++ b/src/components/mui/__tests__/mui-table.test.js @@ -103,26 +103,21 @@ describe("MuiTable", () => { test("renders edit button when onEdit is provided", () => { setup({ onEdit: jest.fn() }); - const editBtns = screen.getAllByRole("button"); - expect(editBtns.length).toBeGreaterThan(0); + expect(screen.getAllByTestId("action-edit")).toHaveLength(2); }); test("calls onEdit when edit button is clicked", async () => { const onEdit = jest.fn(); setup({ onEdit }); - const buttons = screen.getAllByRole("button"); - await userEvent.click(buttons[0]); - expect(onEdit).toHaveBeenCalledWith( - expect.objectContaining({ id: 1 }) - ); + await userEvent.click(screen.getAllByTestId("action-edit")[0]); + expect(onEdit).toHaveBeenCalledWith(expect.objectContaining({ id: 1 })); }); test("calls showConfirmDialog and then onDelete when delete confirmed", async () => { const onDelete = jest.fn(); showConfirmDialog.mockResolvedValueOnce(true); setup({ onDelete }); - const buttons = screen.getAllByRole("button"); - await userEvent.click(buttons[0]); + await userEvent.click(screen.getAllByTestId("action-delete")[0]); await new Promise((r) => setTimeout(r, 0)); expect(showConfirmDialog).toHaveBeenCalled(); expect(onDelete).toHaveBeenCalledWith(1); @@ -132,12 +127,42 @@ describe("MuiTable", () => { const onDelete = jest.fn(); showConfirmDialog.mockResolvedValueOnce(false); setup({ onDelete }); - const buttons = screen.getAllByRole("button"); - await userEvent.click(buttons[0]); + await userEvent.click(screen.getAllByTestId("action-delete")[0]); await new Promise((r) => setTimeout(r, 0)); expect(onDelete).not.toHaveBeenCalled(); }); + test("calls onSort when sortable column header is clicked", async () => { + const onSort = jest.fn(); + const sortableColumns = [ + { columnKey: "name", header: "Name", sortable: true }, + { columnKey: "role", header: "Role" } + ]; + setup({ columns: sortableColumns, onSort, options: { sortCol: "", sortDir: 1 } }); + await userEvent.click(screen.getByText("Name")); + expect(onSort).toHaveBeenCalledWith("name", -1); + }); + + test("calls onSelect when select button is clicked", async () => { + const onSelect = jest.fn(); + setup({ onSelect }); + await userEvent.click(screen.getAllByTestId("action-select")[0]); + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 1 })); + }); + + test("calls onArchive when archive button is clicked", async () => { + const onArchive = jest.fn(); + setup({ onArchive }); + await userEvent.click(screen.getAllByTestId("action-archive")[0]); + expect(onArchive).toHaveBeenCalledWith(expect.objectContaining({ id: 1 })); + }); + + test("hides delete button when canDelete returns false", () => { + const onDelete = jest.fn(); + setup({ onDelete, canDelete: (row) => row.id !== 1 }); + expect(screen.getAllByTestId("action-delete")).toHaveLength(1); + }); + test("renders pagination when perPage and currentPage are set", () => { setup(); expect(screen.getByTestId("pagination")).toBeInTheDocument(); diff --git a/src/components/mui/__tests__/nav-bar.test.js b/src/components/mui/__tests__/nav-bar.test.js new file mode 100644 index 00000000..79e12650 --- /dev/null +++ b/src/components/mui/__tests__/nav-bar.test.js @@ -0,0 +1,54 @@ +/** + * 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 } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import NavBar from "../NavBar/index"; + +const defaultProps = { + title: "My App", + profilePic: "", + isLoggedUser: false, + onClickLogin: jest.fn(), + initLogOut: jest.fn() +}; + +beforeEach(() => jest.clearAllMocks()); + +describe("NavBar", () => { + test("renders the title", () => { + render(); + expect(screen.getByText("My App")).toBeInTheDocument(); + }); + + test("renders login button when user is not logged in", () => { + render(); + expect(screen.getByRole("button", { name: /buttons\.log_in/i })).toBeInTheDocument(); + }); + + test("does not show login button when user is logged in", () => { + render(); + expect(screen.queryByRole("button", { name: /buttons\.log_in/i })).not.toBeInTheDocument(); + }); + + test("renders without crashing", () => { + const { container } = render(); + expect(container.firstChild).toBeInTheDocument(); + }); +}); diff --git a/src/components/mui/__tests__/notes-modal.test.js b/src/components/mui/__tests__/notes-modal.test.js index 1bdd88f6..f1ee0e97 100644 --- a/src/components/mui/__tests__/notes-modal.test.js +++ b/src/components/mui/__tests__/notes-modal.test.js @@ -33,7 +33,14 @@ const renderModal = (props) => onSubmit={jest.fn()} >
- + ); diff --git a/src/components/mui/__tests__/order-summary.test.js b/src/components/mui/__tests__/order-summary.test.js new file mode 100644 index 00000000..1412d130 --- /dev/null +++ b/src/components/mui/__tests__/order-summary.test.js @@ -0,0 +1,65 @@ +/** + * 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 } +})); + +jest.mock("../../../utils/money", () => ({ + currencyAmountFromCents: (amount) => `$${(amount / 100).toFixed(2)}` +})); + +import React from "react"; +import { render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import OrderSummary from "../OrderSummary/index"; + +const defaultProps = { + amount: 5000, + dueDate: "2026-05-01", + toName: "Alice", + fromName: "Bob" +}; + +describe("OrderSummary", () => { + test("renders the formatted amount", () => { + render(); + expect(screen.getByText("$50.00")).toBeInTheDocument(); + }); + + test("renders toName", () => { + render(); + expect(screen.getByText("Alice")).toBeInTheDocument(); + }); + + test("renders fromName", () => { + render(); + expect(screen.getByText("Bob")).toBeInTheDocument(); + }); + + test("renders dueDate", () => { + render(); + expect(screen.getByText(/2026-05-01/)).toBeInTheDocument(); + }); + + test("renders a skeleton when amount is null/undefined", () => { + render(); + expect(document.querySelector(".MuiSkeleton-root")).toBeInTheDocument(); + }); + + test("renders $0.00 when amount is 0", () => { + render(); + expect(screen.getByText("$0.00")).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..1e71daaa 100644 --- a/src/components/mui/__tests__/show-confirm-dialog.test.js +++ b/src/components/mui/__tests__/show-confirm-dialog.test.js @@ -16,6 +16,7 @@ jest.mock("react-dom", () => ({ unmountComponentAtNode: jest.fn() })); + jest.mock("../confirm-dialog", () => { const React = require("react"); return { __esModule: true, default: () =>
}; @@ -32,24 +33,27 @@ describe("showConfirmDialog", () => { expect(result).toBeInstanceOf(Promise); }); - test("calls ReactDOM.render to mount the dialog", () => { + 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", () => { + 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("passes title and text to ConfirmDialog", () => { + 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"); diff --git a/src/components/mui/__tests__/status-chip.test.js b/src/components/mui/__tests__/status-chip.test.js new file mode 100644 index 00000000..910d194d --- /dev/null +++ b/src/components/mui/__tests__/status-chip.test.js @@ -0,0 +1,45 @@ +/** + * 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 } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import StatusChip from "../StatusChip/index"; + +describe("StatusChip", () => { + test("renders the status label", () => { + render(); + expect(screen.getByText("Complete")).toBeInTheDocument(); + }); + + test("renders for an unknown status without crashing", () => { + render(); + expect(screen.getByText("Unknown")).toBeInTheDocument(); + }); + + test("renders a chip element", () => { + render(); + // MUI Chip renders with role="button" or as a span depending on whether it's clickable + const chip = screen.getByText("Pending").closest("[class*='Chip']") + || screen.getByText("Pending").parentElement; + expect(chip).toBeInTheDocument(); + }); + + test("renders different statuses correctly", () => { + const { rerender } = render(); + expect(screen.getByText("Complete")).toBeInTheDocument(); + + rerender(); + expect(screen.getByText("Pending")).toBeInTheDocument(); + }); +}); diff --git a/src/components/mui/__tests__/upload-btn.test.js b/src/components/mui/__tests__/upload-btn.test.js new file mode 100644 index 00000000..75460259 --- /dev/null +++ b/src/components/mui/__tests__/upload-btn.test.js @@ -0,0 +1,49 @@ +/** + * 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 } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import UploadBtn from "../UploadBtn/index"; + +beforeEach(() => jest.clearAllMocks()); + +describe("UploadBtn", () => { + test("renders a button", () => { + render(); + expect(screen.getByRole("button")).toBeInTheDocument(); + }); + + test("calls onClick when clicked", async () => { + const onClick = jest.fn(); + render(); + await userEvent.click(screen.getByRole("button")); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + test("is disabled when disabled prop is true", () => { + render(); + expect(screen.getByRole("button")).toBeDisabled(); + }); + + test("is enabled when disabled prop is false", () => { + render(); + expect(screen.getByRole("button")).not.toBeDisabled(); + }); +}); diff --git a/src/components/mui/__tests__/upload-dialog.test.js b/src/components/mui/__tests__/upload-dialog.test.js new file mode 100644 index 00000000..ec8457d4 --- /dev/null +++ b/src/components/mui/__tests__/upload-dialog.test.js @@ -0,0 +1,84 @@ +/** + * 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 } +})); + +jest.mock("../../inputs/upload-input-v3", () => () => ( +
+)); + +import React from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import UploadDialog from "../UploadDialog/index"; + +global.window = { ...global.window, FILE_UPLOAD_API_BASE_URL: "https://api.example.com" }; + +const defaultProps = { + name: "doc", + value: [], + open: true, + fileMeta: { + name: "My Document", + description: "Upload your document here", + max_file_size: 5, + allowed_extensions: ".pdf,.docx" + }, + maxFiles: 1, + onClose: jest.fn(), + onUpload: jest.fn(() => Promise.resolve()) +}; + +beforeEach(() => jest.clearAllMocks()); + +describe("UploadDialog", () => { + test("renders the dialog when open", () => { + render(); + // "upload_input.upload_file" appears in both the title and the submit button + expect(screen.getAllByText("upload_input.upload_file").length).toBeGreaterThan(0); + }); + + test("renders fileMeta name and description", () => { + render(); + expect(screen.getByText("My Document")).toBeInTheDocument(); + expect(screen.getByText("Upload your document here")).toBeInTheDocument(); + }); + + test("does not render content when closed", () => { + render(); + expect(screen.queryByText("My Document")).not.toBeInTheDocument(); + }); + + test("calls onClose when close icon button is clicked", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: /close/i })); + expect(defaultProps.onClose).toHaveBeenCalledTimes(1); + }); + + test("renders the upload input", () => { + render(); + expect(screen.getByTestId("upload-input")).toBeInTheDocument(); + }); + + test("upload button is disabled when no file is selected", () => { + render(); + // The upload button is the one that is not the close button + const buttons = screen.getAllByRole("button"); + const uploadBtn = buttons.find((btn) => btn.textContent.includes("upload_input.upload_file")); + expect(uploadBtn).toBeDisabled(); + }); +}); diff --git a/src/components/mui/table/mui-table.js b/src/components/mui/table/mui-table.js index ff3831a1..97d253e3 100644 --- a/src/components/mui/table/mui-table.js +++ b/src/components/mui/table/mui-table.js @@ -13,7 +13,7 @@ import * as React from "react"; import T from "i18n-react/dist/i18n-react"; -import { isBoolean } from "lodash"; +import {isBoolean} from "lodash"; import { Box, Button, @@ -32,14 +32,12 @@ import EditIcon from "@mui/icons-material/Edit"; import DeleteIcon from "@mui/icons-material/Delete"; import CheckIcon from "@mui/icons-material/Check"; import CloseIcon from "@mui/icons-material/Close"; -import { visuallyHidden } from "@mui/utils"; -import { - DEFAULT_PER_PAGE, - FIFTY_PER_PAGE, - TWENTY_PER_PAGE -} from "../../../utils/constants"; +import {visuallyHidden} from "@mui/utils"; +import {DEFAULT_PER_PAGE, FIFTY_PER_PAGE, TWENTY_PER_PAGE} from "../../../utils/constants"; import showConfirmDialog from "../showConfirmDialog"; import styles from "./mui-table.module.less"; +import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; +import PropTypes from "prop-types"; const ARCHIVED_CELL_SX = { backgroundColor: "background.light", @@ -47,26 +45,30 @@ const ARCHIVED_CELL_SX = { }; const MuiTable = ({ - columns = [], - data = [], - children, - totalRows, - perPage, - currentPage, - onPageChange, - onPerPageChange, - onSort, - options = { sortCol: "", sortDir: 1, disableProp: null }, // disableProp is the prop that will disable the row - getName = (item) => item.name, - onEdit, - onArchive, - onDelete, - canDelete = () => true, - deleteDialogTitle = null, - deleteDialogBody = null, - deleteDialogConfirmText = null, - confirmButtonColor = null -}) => { + columns = [], + data = [], + children, + totalRows, + perPage, + currentPage, + onPageChange, + onPerPageChange, + onSort, + options = {sortCol: "", sortDir: 1, disableProp: null}, // disableProp is the prop that will disable the row + getName = (item) => item.name, + onEdit, + onArchive, + onDelete, + onSelect, + canDelete = () => true, + deleteDialogTitle = null, + deleteDialogBody = null, + deleteDialogConfirmText = null, + confirmButtonColor = null + }) => { + const totalColumnsCount = + columns.length + (onEdit ? 1 : 0) + (onDelete ? 1 : 0) + (onArchive ? 1 : 0) + (onSelect ? 1 : 0); + const handleChangePage = (_, newPage) => { onPageChange(newPage + 1); }; @@ -92,7 +94,7 @@ const MuiTable = ({ customPerPageOptions = [initialPerPage.current]; } - const { sortCol, sortDir } = options; + const {sortCol, sortDir} = options; const getArchivedCellSx = (row) => options.disableProp && row[options.disableProp] ? ARCHIVED_CELL_SX : null; @@ -109,7 +111,7 @@ const MuiTable = ({ typeof deleteDialogBody === "function" ? deleteDialogBody(getName(item)) : deleteDialogBody || - `${T.translate("general.row_remove_warning")} ${getName(item)}`, + `${T.translate("general.row_remove_warning")} ${getName(item)}`, type: "warning", showCancelButton: true, confirmButtonColor: confirmButtonColor || "#DD6B55", @@ -129,9 +131,9 @@ const MuiTable = ({ if (isBoolean(row[col.columnKey])) { return row[col.columnKey] ? ( - + ) : ( - + ); } @@ -139,15 +141,15 @@ const MuiTable = ({ }; return ( - - + + {/* TABLE HEADER */} - + {columns.map((col) => ( ))} - {onEdit && } - {onArchive && } - {onDelete && } + {onEdit && } + {onArchive && } + {onDelete && } + {onSelect && } @@ -211,10 +214,10 @@ const MuiTable = ({ - onEdit(row)}> - + onEdit(row)} data-testid="action-edit"> + )} @@ -222,7 +225,7 @@ const MuiTable = ({ {onArchive && (