diff --git a/src/pages/Settings/ActionBox.tsx b/src/pages/Settings/ActionBox.tsx index ee8eadcc..6344c41c 100644 --- a/src/pages/Settings/ActionBox.tsx +++ b/src/pages/Settings/ActionBox.tsx @@ -1,25 +1,40 @@ -import type { FC, ReactElement } from "react"; +import type { FC, ReactElement, ReactNode } from "react"; export type Props = { name: string | ReactElement; actionName: string; action: () => void; + showChild: boolean; + children: ReactNode; }; -const ActionBox: FC = ({ name, action, actionName }) => { +/** + * displays a box with a title and a button which triggers an action (e.g. reboot) + * has a child component which is displayed if showChild is true + */ +const ActionBox: FC = ({ + name, + action, + actionName, + showChild, + children, +}) => { return ( -
-
-
-

- {name} -

- -
-
-
+ <> + {showChild && children} +
+
+
+

+ {name} +

+ +
+
+
+ ); }; diff --git a/src/pages/Settings/DebugLogBox.tsx b/src/pages/Settings/DebugLogBox.tsx index 20d78cd4..e7945dd4 100644 --- a/src/pages/Settings/DebugLogBox.tsx +++ b/src/pages/Settings/DebugLogBox.tsx @@ -7,6 +7,9 @@ import { toast } from "react-toastify"; import { checkError } from "utils/checkError"; import { instance } from "utils/interceptor"; +/** + * Displays a button to generate a debug report which is downloaded as a file + */ const DebugLogBox: FC = () => { const { t } = useTranslation(); const { isGeneratingReport, setIsGeneratingReport } = useContext(AppContext); diff --git a/src/pages/Settings/I18nBox.tsx b/src/pages/Settings/I18nBox.tsx index 35da452c..e29bfa81 100644 --- a/src/pages/Settings/I18nBox.tsx +++ b/src/pages/Settings/I18nBox.tsx @@ -2,6 +2,9 @@ import { FC } from "react"; import { useTranslation } from "react-i18next"; import I18nDropdown from "../../components/I18nDropdown"; +/** + * Displays the current language and allows the user to change it. + */ const I18nBox: FC = () => { const { t } = useTranslation(); diff --git a/src/pages/Settings/RebootModal.tsx b/src/pages/Settings/RebootModal.tsx new file mode 100644 index 00000000..75d41ac9 --- /dev/null +++ b/src/pages/Settings/RebootModal.tsx @@ -0,0 +1,64 @@ +import { FC, useContext } from "react"; +import { createPortal } from "react-dom"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router"; +import ModalDialog from "../../layouts/ModalDialog"; +import { AppContext } from "../../context/app-context"; +import { instance } from "../../utils/interceptor"; +import { MODAL_ROOT } from "../../utils"; +import { HttpStatusCode } from "axios"; + +export type Props = { + confirmText: string; + confirmEndpoint?: string; // TODO #345 remove + onConfirm?: () => void; + onClose: () => void; +}; + +const btnClasses = + "w-full xl:w-1/2 text-center h-10 m-2 bg-yellow-500 hover:bg-yellow-400 rounded text-white"; + +const RebootModal: FC = ({ + confirmText, + confirmEndpoint, + onConfirm, + onClose, +}) => { + const { t } = useTranslation(); + const { setIsLoggedIn } = useContext(AppContext); + const navigate = useNavigate(); + + const shutdownHandler = async () => { + if (confirmEndpoint) { + const resp = await instance.post(confirmEndpoint); + if (resp.status === HttpStatusCode.Ok) { + setIsLoggedIn(false); + navigate("/login"); + } + } + }; + + return createPortal( + + {confirmText} +
+ + {onConfirm && ( + + )} + {!onConfirm && ( + + )} +
+
, + MODAL_ROOT, + ); +}; + +export default RebootModal; diff --git a/src/pages/Settings/ShutdownModal.tsx b/src/pages/Settings/ShutdownModal.tsx new file mode 100644 index 00000000..6066372a --- /dev/null +++ b/src/pages/Settings/ShutdownModal.tsx @@ -0,0 +1,64 @@ +import { FC, useContext } from "react"; +import { createPortal } from "react-dom"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router"; +import ModalDialog from "../../layouts/ModalDialog"; +import { AppContext } from "../../context/app-context"; +import { instance } from "../../utils/interceptor"; +import { MODAL_ROOT } from "../../utils"; +import { HttpStatusCode } from "axios"; + +export type Props = { + confirmText: string; + confirmEndpoint?: string; // TODO #345 remove + onConfirm?: () => void; + onClose: () => void; +}; + +const btnClasses = + "w-full xl:w-1/2 text-center h-10 m-2 bg-yellow-500 hover:bg-yellow-400 rounded text-white"; + +const ShutdownModal: FC = ({ + confirmText, + confirmEndpoint, + onConfirm, + onClose, +}) => { + const { t } = useTranslation(); + const { setIsLoggedIn } = useContext(AppContext); + const navigate = useNavigate(); + + const shutdownHandler = async () => { + if (confirmEndpoint) { + const resp = await instance.post(confirmEndpoint); + if (resp.status === HttpStatusCode.Ok) { + setIsLoggedIn(false); + navigate("/login"); + } + } + }; + + return createPortal( + + {confirmText} +
+ + {onConfirm && ( + + )} + {!onConfirm && ( + + )} +
+
, + MODAL_ROOT, + ); +}; + +export default ShutdownModal; diff --git a/src/pages/Settings/VersionBox.tsx b/src/pages/Settings/VersionBox.tsx index b55c524a..de6a0d67 100644 --- a/src/pages/Settings/VersionBox.tsx +++ b/src/pages/Settings/VersionBox.tsx @@ -7,6 +7,9 @@ type Props = { apiVersion: string; }; +/** + * Displays the versions of RaspiBlitz, the WebUI and the Blitz-API + */ const VersionBox: FC = ({ platformVersion, apiVersion }) => { const { t } = useTranslation(); return ( diff --git a/src/pages/Settings/__tests__/ActionBox.test.tsx b/src/pages/Settings/__tests__/ActionBox.test.tsx new file mode 100644 index 00000000..8e82169e --- /dev/null +++ b/src/pages/Settings/__tests__/ActionBox.test.tsx @@ -0,0 +1,76 @@ +import { render, fireEvent } from "test-utils"; +import ActionBox from "../ActionBox"; + +describe("ActionBox component", () => { + it("renders the name prop", () => { + const { getByText } = render( + {}} + actionName="Test Action" + showChild={false} + > +
Test Child
+
, + ); + expect(getByText("Test Name")).toBeInTheDocument(); + }); + + it("renders the action button with the correct text", () => { + const { getByText } = render( + {}} + actionName="Test Action" + showChild={false} + > +
Test Child
+
, + ); + expect(getByText("Test Action")).toBeInTheDocument(); + }); + + it("calls the action prop when the button is clicked", () => { + const mockAction = vitest.fn(); + const { getByText } = render( + +
Test Child
+
, + ); + fireEvent.click(getByText("Test Action")); + expect(mockAction).toHaveBeenCalled(); + }); + + it("renders the child component when showChild is true", () => { + const { getByText } = render( + {}} + actionName="Test Action" + showChild={true} + > +
Test Child
+
, + ); + expect(getByText("Test Child")).toBeInTheDocument(); + }); + + it("does not render the child component when showChild is false", () => { + const { queryByText } = render( + {}} + actionName="Test Action" + showChild={false} + > +
Test Child
+
, + ); + expect(queryByText("Test Child")).toBeNull(); + }); +}); diff --git a/src/pages/Settings/__tests__/DebugLogBox.test.tsx b/src/pages/Settings/__tests__/DebugLogBox.test.tsx new file mode 100644 index 00000000..3b524063 --- /dev/null +++ b/src/pages/Settings/__tests__/DebugLogBox.test.tsx @@ -0,0 +1,51 @@ +import { toast } from "react-toastify"; +import { fireEvent, render, screen } from "test-utils"; +import DebugLogBox from "../DebugLogBox"; + +vitest.mock("react-toastify", () => ({ + toast: { + info: vitest.fn(), + error: vitest.fn(), + }, +})); + +describe("DebugLogBox", () => { + it("renders without crashing", () => { + render(); + }); + + it("displays a button to generate a debug report", () => { + render(); + const button = screen.getByRole("button", { + name: /settings.generate/i, + }); + expect(button).toBeInTheDocument(); + }); + + it("sets isGeneratingReport to true when the button is clicked", async () => { + const setIsGeneratingReport = vitest.fn(); + render(, { + providerOptions: { + appProps: { + setIsGeneratingReport, + }, + }, + }); + const button = screen.getByRole("button", { + name: /settings.generate/i, + }); + fireEvent.click(button); + expect(setIsGeneratingReport).toHaveBeenCalledWith(true); + }); + + it("displays a loading toast while the report is being generated", async () => { + render(); + const button = screen.getByRole("button", { + name: /settings.generate/i, + }); + fireEvent.click(button); + expect(toast.info).toHaveBeenCalledWith(expect.any(String), { + isLoading: true, + }); + }); +}); diff --git a/src/pages/Settings/index.tsx b/src/pages/Settings/index.tsx index a0ddc348..267340de 100644 --- a/src/pages/Settings/index.tsx +++ b/src/pages/Settings/index.tsx @@ -1,18 +1,22 @@ import { FC, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import ConfirmModal from "../../components/ConfirmModal"; import useSSE from "../../hooks/use-sse"; import { enableGutter } from "../../utils"; import ActionBox from "./ActionBox"; import ChangePwModal from "./ChangePwModal"; import DebugLogBox from "./DebugLogBox"; import I18nBox from "./I18nBox"; +import RebootModal from "./RebootModal"; +import ShutdownModal from "./ShutdownModal"; import VersionBox from "./VersionBox"; +/** + * Displays the settings page. + */ const Settings: FC = () => { const { t } = useTranslation(); - const [confirmShutdown, setConfirmShutdown] = useState(false); - const [confirmReboot, setConfirmReboot] = useState(false); + const [showShutdownModal, setShowShutdownModal] = useState(false); + const [showRebootModal, setShowRebootModal] = useState(false); const [showPwModal, setShowPwModal] = useState(false); const { systemInfo } = useSSE(); @@ -20,68 +24,46 @@ const Settings: FC = () => { enableGutter(); }, []); - const showShutdownModalHandler = () => { - setConfirmShutdown(true); - }; - - const hideShutdownModalHandler = () => { - setConfirmShutdown(false); - }; - - const showRebootModalHandler = () => { - setConfirmReboot(true); - }; - - const hideRebootModalHandler = () => { - setConfirmReboot(false); - }; - - const showPwModalHandler = () => { - setShowPwModal(true); - }; - - const hidePwModalHandler = () => { - setShowPwModal(false); - }; - return (
+ action={() => setShowPwModal(true)} + showChild={showPwModal} + > + setShowPwModal(false)} /> + + action={() => setShowRebootModal(true)} + showChild={showRebootModal} + > + setShowRebootModal(false)} + confirmEndpoint="/system/reboot" + /> + + action={() => setShowShutdownModal(true)} + showChild={showShutdownModal} + > + setShowShutdownModal(false)} + confirmEndpoint="/system/shutdown" + /> + - {showPwModal && } - {confirmReboot && ( - - )} - {confirmShutdown && ( - - )}
); }; diff --git a/src/testServer.ts b/src/testServer.ts index 7b4d92bc..8c91a1b9 100644 --- a/src/testServer.ts +++ b/src/testServer.ts @@ -2,13 +2,14 @@ import { rest } from "msw"; import { setupServer } from "msw/node"; const server = setupServer( + // Catch-all for unhandled requests rest.get("*", (req, res, ctx) => { console.error(`Add request handler for ${req.url.toString()}`); return res( ctx.status(500), - ctx.json({ error: "Missing request handler." }) + ctx.json({ error: "Missing request handler." }), ); - }) + }), ); beforeAll(() => server.listen()); diff --git a/src/utils/test-utils.tsx b/src/utils/test-utils.tsx index 5422b404..29fa5fc0 100644 --- a/src/utils/test-utils.tsx +++ b/src/utils/test-utils.tsx @@ -47,7 +47,7 @@ const customRender = ( sseProps?: Partial; appProps?: Partial; }; - } + }, ) => render(ui, { wrapper: (props: any) => ( diff --git a/tsconfig.json b/tsconfig.json index 2b4aaf39..7765a847 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,16 @@ { "compilerOptions": { "target": "ESNext", - "lib": ["dom", "dom.iterable", "esnext"], - "types": ["vite/client", "vite-plugin-svgr/client", "vitest/globals"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "types": [ + "vite/client", + "vite-plugin-svgr/client", + "vitest/globals" + ], "allowJs": false, "skipLibCheck": true, "esModuleInterop": true, @@ -18,8 +26,15 @@ "jsx": "react-jsx", "baseUrl": "src", "paths": { - "test-utils": ["./utils/test-utils"] + "test-utils": [ + "./utils/test-utils" + ], + "@": [ + "src" + ], } }, - "include": ["src"] -} + "include": [ + "src" + ] +} \ No newline at end of file