diff --git a/package-lock.json b/package-lock.json index 073b3337..499bfb9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11246,21 +11246,6 @@ "optional": true } } - }, - "node_modules/@next/swc-win32-ia32-msvc": { - "version": "14.2.15", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.15.tgz", - "integrity": "sha512-fyTE8cklgkyR1p03kJa5zXEaZ9El+kDNM5A+66+8evQS5e/6v0Gk28LqA0Jet8gKSOyP+OTm/tJHzMlGdQerdQ==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } } } } diff --git a/public/locales/en/confirmationPage.json b/public/locales/en/confirmationPage.json index 58cad01e..92e20f47 100644 --- a/public/locales/en/confirmationPage.json +++ b/public/locales/en/confirmationPage.json @@ -42,6 +42,10 @@ "nutrients": "Nutrients", "recordKeeping": "Record Keeping" }, + "notes": { + "sectionTitle": "Notes", + "placeholder": "Add a note" + }, "bilingualTable": { "tableHeaders": { "english": "English", diff --git a/public/locales/fr/confirmationPage.json b/public/locales/fr/confirmationPage.json index 69dbe504..719ef282 100644 --- a/public/locales/fr/confirmationPage.json +++ b/public/locales/fr/confirmationPage.json @@ -42,6 +42,10 @@ "nutrients": "Nutriments", "recordKeeping": "Tenue de registres" }, + "notes": { + "sectionTitle": "Notes", + "placeholder": "Ajouter une note" + }, "bilingualTable": { "tableHeaders": { "english": "Anglais", diff --git a/src/app/__tests__/HomePage.test.tsx b/src/app/__tests__/HomePage.test.tsx index 916505ed..27d8ca9d 100644 --- a/src/app/__tests__/HomePage.test.tsx +++ b/src/app/__tests__/HomePage.test.tsx @@ -1,6 +1,9 @@ /* eslint-disable react/display-name */ /* eslint-disable react-hooks/rules-of-hooks */ +import FileUploaded from "@/classe/File"; import useUploadedFilesStore from "@/stores/fileStore"; +import useLabelDataStore from "@/stores/labelDataStore"; +import { VERIFIED_LABEL_DATA } from "@/utils/client/constants"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { useTranslation } from "react-i18next"; @@ -85,15 +88,15 @@ describe("HomePage Component", () => { fireEvent.mouseEnter(fileElement); await waitFor(() => { - - // Find and click the delete button - const deleteButton = screen.getByTestId("delete-hello.png"); - fireEvent.click(deleteButton); + // Find and click the delete button + const deleteButton = screen.getByTestId("delete-hello.png"); + fireEvent.click(deleteButton); }); - // Check that the file was removed - expect(screen.queryByTestId("file-element-hello.png")).not.toBeInTheDocument(); + expect( + screen.queryByTestId("file-element-hello.png"), + ).not.toBeInTheDocument(); }); it("should allow file uploads via drag and drop", async () => { @@ -152,14 +155,15 @@ describe("HomePage Component", () => { fireEvent.mouseEnter(fileElement); await waitFor(() => { - - // Find and click the delete button - const deleteButton = screen.getByTestId("delete-hello.png"); - fireEvent.click(deleteButton); + // Find and click the delete button + const deleteButton = screen.getByTestId("delete-hello.png"); + fireEvent.click(deleteButton); }); // Check that the file was removed - expect(screen.queryByTestId("file-element-hello.png")).not.toBeInTheDocument(); + expect( + screen.queryByTestId("file-element-hello.png"), + ).not.toBeInTheDocument(); }); it("should allow file upload via input and keep hover effect until delete button is clicked", async () => { @@ -182,14 +186,15 @@ describe("HomePage Component", () => { fireEvent.mouseEnter(fileElement); await waitFor(() => { - - // Find and click the delete button - const deleteButton = screen.getByTestId("delete-hello.png"); - fireEvent.click(deleteButton); + // Find and click the delete button + const deleteButton = screen.getByTestId("delete-hello.png"); + fireEvent.click(deleteButton); }); // Check that the file was removed - expect(screen.queryByTestId("file-element-hello.png")).not.toBeInTheDocument(); + expect( + screen.queryByTestId("file-element-hello.png"), + ).not.toBeInTheDocument(); }); it("The button submit should be visible when a file is uploaded", async () => { @@ -248,7 +253,7 @@ describe("HomePage Component", () => { userEvent.upload(input, [file, file2]); // Check that the file was uploaded and appears in the list. - const fileElement = await screen.findByTestId("file-element-hello.png"); + const fileElement = await screen.findByTestId("file-element-hello.png"); expect(fileElement).toBeInTheDocument(); // Check that the file was uploaded and appears in the list. @@ -303,4 +308,22 @@ describe("HomePage Component", () => { expect(mockedRouterPush).toHaveBeenCalledWith("/label-data-validation"); }); + + it("should clear uploaded files and reset label data on mount", () => { + useUploadedFilesStore.getState().addUploadedFile( + FileUploaded.newFile( + { username: "testUser" }, + "/uploads/test1.png", + new File(["dummy content"], "test1.png", { + type: "image/png", + }), + ), + ); + useLabelDataStore.getState().setLabelData(VERIFIED_LABEL_DATA); + expect(useUploadedFilesStore.getState().uploadedFiles.length).toBe(1); + expect(useLabelDataStore.getState().labelData).not.toBeNull(); + render(); + expect(useUploadedFilesStore.getState().uploadedFiles.length).toBe(0); + expect(useLabelDataStore.getState().labelData).toBe(null); + }); }); diff --git a/src/app/label-data-confirmation/__tests__/ConfirmationPage.test.tsx b/src/app/label-data-confirmation/__tests__/ConfirmationPage.test.tsx index 8b0ecbdf..20e1b737 100644 --- a/src/app/label-data-confirmation/__tests__/ConfirmationPage.test.tsx +++ b/src/app/label-data-confirmation/__tests__/ConfirmationPage.test.tsx @@ -1,14 +1,15 @@ import FileUploaded from "@/classe/File"; import useUploadedFilesStore from "@/stores/fileStore"; import useLabelDataStore from "@/stores/labelDataStore"; -import { Quantity } from "@/types/types"; import { VERIFIED_LABEL_DATA, VERIFIED_LABEL_DATA_WITH_ID, } from "@/utils/client/constants"; import { fireEvent, render, screen } from "@testing-library/react"; import axios from "axios"; -import LabelDataConfirmationPage, { QuantityChips } from "../page"; +import LabelDataConfirmationPage from "../page"; +import { QuantityChips } from "@/components/QuantityChip"; +import { Quantity } from "@/types/types"; const mockedRouterPush = jest.fn(); jest.mock("next/navigation", () => ({ @@ -222,3 +223,43 @@ describe("QuantityChips", () => { expect(screen.queryByText("g")).not.toBeInTheDocument(); }); }); + +describe("Notes Section Tests", () => { + it("should render the notes section with a textbox", () => { + render(); + const notesSection = screen.getByTestId("notes-section"); + const notesTextbox = screen.getByTestId("notes-textbox"); + expect(notesSection).toBeInTheDocument(); + expect(notesTextbox).toBeInTheDocument(); + }); + + it("should update the comment value when text is entered", () => { + useLabelDataStore.getState().setLabelData(VERIFIED_LABEL_DATA); + expect(useLabelDataStore.getState().labelData?.comment).toBe("Compliant with regulations."); + render(); + const notesTextbox = screen + .getByTestId("notes-textbox") + .querySelector("textarea"); + expect(notesTextbox).toBeInTheDocument(); + fireEvent.change(notesTextbox!, { target: { value: "New note" } }); + expect(useLabelDataStore.getState().labelData?.comment).toBe("New note"); + expect(notesTextbox).toHaveValue("New note"); + }); + + it("should toggle the notes textbox disabled state when the checkbox is clicked", () => { + render(); + const notesTextbox = screen + .getByTestId("notes-textbox") + .querySelector("textarea"); + const checkboxInput = screen + .getByTestId("confirmation-checkbox") + .querySelector("input"); + expect(notesTextbox).toBeInTheDocument(); + expect(checkboxInput).toBeInTheDocument(); + expect(notesTextbox).not.toBeDisabled(); + fireEvent.click(checkboxInput!); + expect(notesTextbox).toBeDisabled(); + fireEvent.click(checkboxInput!); + expect(notesTextbox).not.toBeDisabled(); + }); +}); diff --git a/src/app/label-data-confirmation/page.tsx b/src/app/label-data-confirmation/page.tsx index b33d1077..084e65d7 100644 --- a/src/app/label-data-confirmation/page.tsx +++ b/src/app/label-data-confirmation/page.tsx @@ -1,18 +1,17 @@ "use client"; import ImageViewer from "@/components/ImageViewer"; +import LoadingButton from "@/components/LoadingButton"; +import { QuantityChips } from "@/components/QuantityChip"; import useAlertStore from "@/stores/alertStore"; import useUploadedFilesStore from "@/stores/fileStore"; import useLabelDataStore from "@/stores/labelDataStore"; -import { BilingualField, LabelData, Quantity } from "@/types/types"; +import { BilingualField, LabelData } from "@/types/types"; import { processAxiosError } from "@/utils/client/apiErrors"; import { isAllVerified } from "@/utils/client/fieldValidation"; import useBreakpoints from "@/utils/client/useBreakpoints"; import { Box, - Button, Checkbox, - Chip, - CircularProgress, Container, FormControlLabel, FormGroup, @@ -25,6 +24,7 @@ import { TableContainer, TableHead, TableRow, + TextField, Tooltip, Typography, } from "@mui/material"; @@ -37,14 +37,20 @@ import { useTranslation } from "react-i18next"; const LabelDataConfirmationPage = () => { const labelData = useLabelDataStore((state) => state.labelData); const setLabelData = useLabelDataStore((state) => state.setLabelData); + const resetLabelData = useLabelDataStore((state) => state.resetLabelData); + const setComment = useLabelDataStore((state) => state.setComment); const uploadedFiles = useUploadedFilesStore((state) => state.uploadedFiles); + const clearUploadedFiles = useUploadedFilesStore( + (state) => state.clearUploadedFiles, + ); const imageFiles = uploadedFiles.map((file) => file.getFile()); const router = useRouter(); - const [loading, setLoading] = useState(false); + const [confirmLoading, setConfirmLoading] = useState(false); + const [editLoading, setEditLoading] = useState(false); const showAlert = useAlertStore((state) => state.showAlert); const [confirmed, setConfirmed] = useState(false); const { t } = useTranslation("confirmationPage"); - const [isRetractedView, setIsRetractedView] = useState(false); + const [isRetractedView, setIsRetractedView] = useState(true); const getAuthHeader = () => { return "Basic " + btoa(`${atob(Cookies.get("token") ?? "")}:`); @@ -52,7 +58,7 @@ const LabelDataConfirmationPage = () => { const putLabelData = (labelData: LabelData, signal: AbortSignal) => { const confirmedLabelData = { ...labelData, confirmed: true }; - setLoading(true); + setConfirmLoading(true); axios .put( `/api-next/inspections/${confirmedLabelData.inspectionId}`, @@ -64,6 +70,8 @@ const LabelDataConfirmationPage = () => { ) .then(() => { showAlert("Label data saved successfully.", "success"); + resetLabelData(); + clearUploadedFiles(); router.push("/"); }) .catch((error) => { @@ -73,7 +81,7 @@ const LabelDataConfirmationPage = () => { ); }) .finally(() => { - setLoading(false); + setConfirmLoading(false); }); }; @@ -84,7 +92,7 @@ const LabelDataConfirmationPage = () => { formData.append("files", file); }); formData.append("labelData", JSON.stringify(labelData)); - setLoading(true); + setConfirmLoading(true); axios .post("/api-next/inspections", formData, { headers: { Authorization: getAuthHeader() }, @@ -94,7 +102,10 @@ const LabelDataConfirmationPage = () => { if (!response.data.inspectionId) { throw new Error("ID missing in initial label data saving response."); } - const _labelData = { ...labelData, inspectionId: response.data.inspectionId }; + const _labelData = { + ...labelData, + inspectionId: response.data.inspectionId, + }; setLabelData(_labelData); putLabelData(_labelData, signal); }) @@ -105,11 +116,12 @@ const LabelDataConfirmationPage = () => { ); }) .finally(() => { - setLoading(false); + setConfirmLoading(false); }); }; const handleEditClick = () => { + setEditLoading(true); if (labelData?.inspectionId) { router.push(`/label-data-validation/${labelData.inspectionId}`); } else { @@ -157,10 +169,6 @@ const LabelDataConfirmationPage = () => { } }, [imageFiles, labelData, router, showAlert]); - useEffect(() => { - console.debug("Label data:", labelData); - }, [labelData, confirmed]); - const { isDownXs, isBetweenXsSm, isBetweenSmMd, isBetweenMdLg } = useBreakpoints(); const isLgOrBelow = @@ -168,7 +176,7 @@ const LabelDataConfirmationPage = () => { return ( @@ -176,7 +184,7 @@ const LabelDataConfirmationPage = () => { className="flex flex-col lg:flex-row gap-4 my-4 lg:h-[85vh] lg:min-h-[500px] " data-testid="main-content" > - {!isRetractedView && ( + {isRetractedView && ( { )} { data-testid="retract-button" > - {isRetractedView ? ( + {!isRetractedView ? ( isLgOrBelow ? ( { )} - {!isRetractedView && ( - - - {/* Title */} + + + + {/* Title */} + + {t("pageTitle")} + + + + {/* Label section */} +
+ {/* Base Information */} + - {t("pageTitle")} + {t("baseInformation.sectionTitle")} + + + + + + + {t("baseInformation.tableHeaders.name")} + + + + + {t( + "baseInformation.tableHeaders.registrationNumber", + )} + + + + + {t("baseInformation.tableHeaders.lotNumber")} + + + + + {t("baseInformation.tableHeaders.npk")} + + + + + {t("baseInformation.tableHeaders.weight")} + + + + + {t("baseInformation.tableHeaders.density")} + + + + + {t("baseInformation.tableHeaders.volume")} + + + + + + + + + {labelData?.baseInformation.name.value} + + + + + { + labelData?.baseInformation.registrationNumber + .value + } + + + + + {labelData?.baseInformation.lotNumber.value} + + + + + {labelData?.baseInformation.npk.value} + + + + + + + + + + + + + +
+
- - {/* Base Information */} - - - {t("baseInformation.sectionTitle")} - - - - - - - - {t("baseInformation.tableHeaders.name")} - + {/* Organizations Table */} + + + {t("organizations.sectionTitle")} + + +
+ + + + + {t("organizations.tableHeaders.name")} + + + + + {t("organizations.tableHeaders.address")} + + + + + {t("organizations.tableHeaders.website")} + + + + + {t("organizations.tableHeaders.phoneNumber")} + + + + + + {labelData?.organizations?.map((org, index) => ( + + + {org.name.value} - - - {t( - "baseInformation.tableHeaders.registrationNumber", - )} - + + {org.address.value} - - - {t("baseInformation.tableHeaders.lotNumber")} + + + + {org.website.value} + - - - {t("baseInformation.tableHeaders.npk")} + + + + {org.phoneNumber.value} + - + + ))} + +
+
+
+ + {/* Cautions */} + + + {t("cautions.sectionTitle")} + + + + + {/* Instructions */} + + + {t("instructions.sectionTitle")} + + + + + {/* Guaranteed Analysis */} + + + {t("guaranteedAnalysis.sectionTitle")} + + + {/* Title Section */} + + + {t("guaranteedAnalysis.title")} + + + + + + - {t("baseInformation.tableHeaders.weight")} + {t("guaranteedAnalysis.tableHeaders.english")} - + - {t("baseInformation.tableHeaders.density")} + {t("guaranteedAnalysis.tableHeaders.french")} - + - {t("baseInformation.tableHeaders.volume")} + {t("guaranteedAnalysis.tableHeaders.isMinimal")} - - - - {labelData?.baseInformation.name.value} - - - + + - { - labelData?.baseInformation.registrationNumber - .value - } + {labelData?.guaranteedAnalysis.titleEn.value} - + - {labelData?.baseInformation.lotNumber.value} + {labelData?.guaranteedAnalysis.titleFr.value} - + - {labelData?.baseInformation.npk.value} + {labelData?.guaranteedAnalysis.isMinimal.value + ? t("yes") + : t("no")} - - - - - - - - -
- {/* Organizations Table */} - - - {t("organizations.sectionTitle")} - - - - - - - - {t("organizations.tableHeaders.name")} - - - - - {t("organizations.tableHeaders.address")} - - - - - {t("organizations.tableHeaders.website")} - - - - - {t("organizations.tableHeaders.phoneNumber")} - - - - - - {labelData?.organizations?.map((org, index) => ( - - - {org.name.value} - - - {org.address.value} - - - - - {org.website.value} - - - - - - - {org.phoneNumber.value} - - - - - ))} - -
-
-
- - {/* Cautions */} - - - {t("cautions.sectionTitle")} + {/* Nutrients Section */} + + + {t("guaranteedAnalysis.nutrients")} + - {/* Instructions */} - - - {t("instructions.sectionTitle")} - - - - - {/* Guaranteed Analysis */} - - - {t("guaranteedAnalysis.sectionTitle")} - - - {/* Title Section */} - - - {t("guaranteedAnalysis.title")} - - - - - - - - {t("guaranteedAnalysis.tableHeaders.english")} - - - - - {t("guaranteedAnalysis.tableHeaders.french")} - - - - - {t("guaranteedAnalysis.tableHeaders.isMinimal")} - - - - - - - - - {labelData?.guaranteedAnalysis.titleEn.value} - - - - - {labelData?.guaranteedAnalysis.titleFr.value} - - - - - {labelData?.guaranteedAnalysis.isMinimal.value - ? t("yes") - : t("no")} - - - - -
-
-
- - {/* Nutrients Section */} - + {/* Ingredients */} + + + {t("ingredients.sectionTitle")} + + {!labelData?.ingredients?.recordKeeping?.value ? ( + - {t("guaranteedAnalysis.nutrients")} + {t("ingredients.nutrients")} - - - {/* Ingredients */} - - - {t("ingredients.sectionTitle")} + ) : ( + + {t("ingredients.recordKeeping")} - {!labelData?.ingredients?.recordKeeping?.value ? ( - - - {t("ingredients.nutrients")} - - - - ) : ( - - {t("ingredients.recordKeeping")} - - )} - + )} - {/* Confirmation Section */} - - {t("confirmationSection.prompt")} - {/* Acknowledgment Checkbox */} - - setConfirmed(event.target.checked)} - disabled={loading} - data-testid="confirmation-checkbox" - /> - } - label={ - - {t("confirmationSection.acknowledgment")} - - } - /> - - - {/* Confirm and Edit Buttons */} - - - - - -
- )} - {isRetractedView && ( - <> - - {/* Title */} + {/* Notes */} + - {t("pageTitle")} + {t("notes.sectionTitle")} + setComment(e.target.value)} + disabled={confirmed} + /> - - {/* Left Column: Base Information */} - - {/* Title */} - - {t("baseInformation.sectionTitle")} - - - - - - - {t("baseInformation.tableHeaders.name")} - - - {labelData?.baseInformation.name.value} - - - - - {t( - "baseInformation.tableHeaders.registrationNumber", - )} - - - { - labelData?.baseInformation.registrationNumber - .value - } - - - - - {t("baseInformation.tableHeaders.lotNumber")} - - - {labelData?.baseInformation.lotNumber.value} - - - - - {t("baseInformation.tableHeaders.npk")} - - - {labelData?.baseInformation.npk.value} - - - - - {t("baseInformation.tableHeaders.weight")} - - - - - - - - {t("baseInformation.tableHeaders.density")} - - - - - - - - {t("baseInformation.tableHeaders.volume")} - - - - - - -
-
- - {/* Cautions */} - - - {t("cautions.sectionTitle")} - - - - - {/* Instructions */} - - - {t("instructions.sectionTitle")} - - - - - {/* Guaranteed Analysis */} - - - {t("guaranteedAnalysis.sectionTitle")} - - - {/* Nutrients Section with Clear Headers */} - - - {t("guaranteedAnalysis.nutrients")} - - - - - - - - {t("bilingualTable.tableHeaders.english")} - - - - - {t("bilingualTable.tableHeaders.french")} - - - - - {t("bilingualTable.tableHeaders.value")} - - - - - {t("bilingualTable.tableHeaders.unit")} - - - - - - {labelData?.guaranteedAnalysis.nutrients.map( - (nutrient, index) => ( - - - {nutrient.en} - - - {nutrient.fr} - - - {nutrient.value} - - - {nutrient.unit} - - - ), - )} - -
-
-
-
-
- - {/* Right Column: Organizations */} - - {labelData?.organizations?.map((org, index) => ( - - - {t("organizations.sectionTitle") + " " + (index + 1)} - - - - - - - {t("organizations.tableHeaders.name")} - - {org.name.value} - - - - {t("organizations.tableHeaders.address")} - - {org.address.value} - - - - {t("organizations.tableHeaders.website")} - - - - {org.website.value} - - - - - - {t("organizations.tableHeaders.phoneNumber")} - - - - {org.phoneNumber.value} - - - - -
-
-
- ))} -
-
- - - {t("confirmationSection.prompt")} - {/* Acknowledgment Checkbox */} - - - setConfirmed(event.target.checked) - } - disabled={loading} - data-testid="confirmation-checkbox" - /> - } - label={ - - {t("confirmationSection.acknowledgment")} - - } +
+ + {/* Confirmation Section */} + + {t("confirmationSection.prompt")} + {/* Acknowledgment Checkbox */} + + setConfirmed(event.target.checked)} + disabled={confirmLoading} + data-testid="confirmation-checkbox" /> - - - - - - + } + label={ + + {t("confirmationSection.acknowledgment")} + + } + /> + + + {/* Confirm and Edit Buttons */} + + + - - )} +
+
@@ -935,31 +640,6 @@ const LabelDataConfirmationPage = () => { export default LabelDataConfirmationPage; -export interface QuantityChipsProps extends React.ComponentProps { - quantities: Quantity[] | undefined; -} - -export const QuantityChips = React.forwardRef< - HTMLDivElement, - QuantityChipsProps ->(({ quantities, ...rest }, ref) => { - return ( - - {quantities - ?.filter((q) => q.value) - .map((q, i) => ( - - ))} - - ); -}); - -QuantityChips.displayName = "QuantityChips"; - interface BilingualTableProps { data: BilingualField[]; } diff --git a/src/app/label-data-validation/page.tsx b/src/app/label-data-validation/page.tsx index d14ea730..d30ef25c 100644 --- a/src/app/label-data-validation/page.tsx +++ b/src/app/label-data-validation/page.tsx @@ -20,7 +20,7 @@ function LabelDataValidationPage() { useEffect(() => { if (uploadedFiles.length === 0) { - showAlert("No files uploaded.", "error"); + showAlert("No files uploaded.", "warning"); router.push("/"); return; } diff --git a/src/app/page.tsx b/src/app/page.tsx index 10f53823..3f99419d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,23 +1,41 @@ "use client"; import Dropzone from "@/components/Dropzone"; +import LoadingButton from "@/components/LoadingButton"; import FileList from "@/components/FileList"; import useUploadedFilesStore from "@/stores/fileStore"; +import useLabelDataStore from "@/stores/labelDataStore"; import type { DropzoneState } from "@/types/types"; -import { Box, Button, Grid2, Tooltip } from "@mui/material"; +import { Box, Grid2, Tooltip } from "@mui/material"; import { useRouter } from "next/navigation"; -import { Suspense, useState } from "react"; +import { Suspense, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; function HomePage() { const { t } = useTranslation("homePage"); const router = useRouter(); + const [loading, setLoading] = useState(false); const [dropzoneState, setDropzoneState] = useState({ visible: false, imageUrl: null, fillPercentage: 0, }); - const { uploadedFiles } = useUploadedFilesStore(); + + const uploadedFiles = useUploadedFilesStore((state) => state.uploadedFiles); + const clearUploadedFiles = useUploadedFilesStore( + (state) => state.clearUploadedFiles, + ); + const resetLabelData = useLabelDataStore((state) => state.resetLabelData); + + useEffect(() => { + clearUploadedFiles(); + resetLabelData(); + }, [clearUploadedFiles, resetLabelData]); + + const handleSubmission = () => { + setLoading(true); + router.push("/label-data-validation"); + }; return ( @@ -61,17 +79,17 @@ function HomePage() { className="w-[90%] max-w-full min-w-[133.44px]" > - + onClick={handleSubmission} + loading={loading} + text={t("submitButton")} + /> diff --git a/src/components/AuthComponents/LoginModal.tsx b/src/components/AuthComponents/LoginModal.tsx index 4384179e..8d2a89b9 100644 --- a/src/components/AuthComponents/LoginModal.tsx +++ b/src/components/AuthComponents/LoginModal.tsx @@ -1,10 +1,11 @@ -import { Box, Button, Modal, Typography } from "@mui/material"; -import AccountCircleIcon from "@mui/icons-material/AccountCircle"; -import LockIcon from "@mui/icons-material/Lock"; import theme from "@/app/theme"; import IconInput from "@/components/IconInput"; +import AccountCircleIcon from "@mui/icons-material/AccountCircle"; +import LockIcon from "@mui/icons-material/Lock"; +import { Box, Modal, Typography } from "@mui/material"; import { useState } from "react"; import { useTranslation } from "react-i18next"; +import LoadingButton from "../LoadingButton"; interface LoginProps { isOpen: boolean; @@ -17,11 +18,14 @@ const LoginModal = ({ isOpen, login, onChangeMode }: LoginProps) => { const [password, setPassword] = useState(""); const [errorMessage, setErrorMessage] = useState(""); const { t } = useTranslation("authentication"); + const [loading, setLoading] = useState(false); const handleSubmit = () => { + setLoading(true); login(username, password).then((message) => { console.log(message); setErrorMessage(message); + setLoading(false); }); }; @@ -104,18 +108,14 @@ const LoginModal = ({ isOpen, login, onChangeMode }: LoginProps) => { > {errorMessage} - + disabled={username === "" || password === ""} + loading={loading} + text={t("login.title")} + className="!bg-white !pointer-events-auto" + data-testid="modal-submit" + /> { const [checkedReminder, setReminderChecked] = useState(false); const [errorMessage, setErrorMessage] = useState(""); const { t } = useTranslation("authentication"); + const [loading, setLoading] = useState(false); const handleSubmit = () => { + setLoading(true); signup(username, password, confirmPassword).then((message) => { setErrorMessage(message); + setLoading(false); }); }; @@ -146,23 +149,19 @@ const SignUpModal = ({ isOpen, signup, onChangeMode }: SignUpProps) => { > {errorMessage} - + loading={loading} + text={t("signup.title")} + className="!bg-white !pointer-events-auto" + data-testid="modal-submit" + /> = ({ + loading, + text, + disabled, + children, + ...props +}) => { + return ( + + ); +}; + +export default LoadingButton; diff --git a/src/components/QuantityChip.tsx b/src/components/QuantityChip.tsx new file mode 100644 index 00000000..edc6c9f6 --- /dev/null +++ b/src/components/QuantityChip.tsx @@ -0,0 +1,28 @@ +import { Quantity } from "@/types/types"; +import { Box, Chip } from "@mui/material"; +import React from "react"; + +export interface QuantityChipsProps extends React.ComponentProps { + quantities: Quantity[] | undefined; +} + +export const QuantityChips = React.forwardRef< + HTMLDivElement, + QuantityChipsProps +>(({ quantities, ...rest }, ref) => { + return ( + + {quantities + ?.filter((q) => q.value) + .map((q, i) => ( + + ))} + + ); +}); + +QuantityChips.displayName = "QuantityChips"; diff --git a/src/components/Sidenav.tsx b/src/components/Sidenav.tsx index eb0e0a62..9884496e 100644 --- a/src/components/Sidenav.tsx +++ b/src/components/Sidenav.tsx @@ -1,4 +1,3 @@ -import useUploadedFilesStore from "@/stores/fileStore"; import BugReportIcon from "@mui/icons-material/BugReport"; import HomeIcon from "@mui/icons-material/Home"; import SearchIcon from "@mui/icons-material/Search"; @@ -16,6 +15,7 @@ import { } from "@mui/material"; import Image from "next/image"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { useTranslation } from "react-i18next"; interface DrawerMenuProps { @@ -24,8 +24,9 @@ interface DrawerMenuProps { } const SideNav = ({ open, onClose }: DrawerMenuProps) => { + const router = useRouter(); const { t } = useTranslation("header"); - const { clearUploadedFiles } = useUploadedFilesStore(); + return ( { href="/" passHref data-testid="new-inspection-button" - onClick={clearUploadedFiles} + onClick={() => router.push("/")} > { />, ); const submitButton = screen.getByText("stepper.submit"); - expect(submitButton).toBeDisabled(); + setTimeout(() => { + expect(submitButton).toBeDisabled(); + }, 350); }); it("enables 'Submit' button when all steps are completed", () => { diff --git a/src/components/__tests__/LoadingButton.test.tsx b/src/components/__tests__/LoadingButton.test.tsx new file mode 100644 index 00000000..5d3b9a0a --- /dev/null +++ b/src/components/__tests__/LoadingButton.test.tsx @@ -0,0 +1,52 @@ +import { ButtonProps } from "@mui/material"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import LoadingButton from "../LoadingButton"; + +describe("LoadingButton", () => { + const defaultProps: ButtonProps & { loading: boolean; text: string } = { + loading: false, + text: "Click me", + }; + + it("renders the button with provided text", () => { + render(); + expect(screen.getByText("Click me")).toBeInTheDocument(); + }); + + it("renders children if provided", () => { + render( + + Custom Child + , + ); + expect(screen.getByText("Custom Child")).toBeInTheDocument(); + }); + + it("disables button when loading is true", () => { + render(); + expect(screen.getByRole("button")).toBeDisabled(); + }); + + it("disables button when disabled prop is true", () => { + render(); + expect(screen.getByRole("button")).toBeDisabled(); + }); + + it("shows loading spinner when loading is true", () => { + render(); + expect(screen.getByTestId("loading-spinner")).toBeInTheDocument(); + }); + + it("does not show spinner when loading is false", () => { + render(); + expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument(); + }); + + it("fires onClick when clicked", async () => { + const onClick = jest.fn(); + render(); + await userEvent.click(screen.getByRole("button")); + expect(onClick).toHaveBeenCalled(); + }); +}); diff --git a/src/components/__tests__/QuantityChip.test.tsx b/src/components/__tests__/QuantityChip.test.tsx new file mode 100644 index 00000000..2f089320 --- /dev/null +++ b/src/components/__tests__/QuantityChip.test.tsx @@ -0,0 +1,19 @@ +import { Quantity } from "@/types/types"; +import { render, screen } from "@testing-library/react"; +import { QuantityChips } from "../QuantityChip"; + +describe("QuantityChips", () => { + it("renders valid quantities, filters out invalid values", () => { + const quantities: Quantity[] = [ + { value: "5", unit: "kg" }, + { value: "", unit: "g" }, + { value: "0", unit: "kg" }, + ]; + + render(); + + expect(screen.getByText("5 kg")).toBeInTheDocument(); + expect(screen.getByText("0 kg")).toBeInTheDocument(); + expect(screen.queryByText("g")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/__tests__/Sidenav.test.tsx b/src/components/__tests__/Sidenav.test.tsx index 264d87b7..f3f2876c 100644 --- a/src/components/__tests__/Sidenav.test.tsx +++ b/src/components/__tests__/Sidenav.test.tsx @@ -1,13 +1,11 @@ /* eslint-disable react-hooks/rules-of-hooks */ -import React from "react"; -import { render, screen, fireEvent } from "@testing-library/react"; -import "@testing-library/jest-dom"; -import SideNav from "../Sidenav"; import { ThemeProvider, createTheme } from "@mui/material/styles"; -import { useRouter } from "next/router"; +import "@testing-library/jest-dom"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { useRouter } from "next/navigation"; import { useTranslation } from "react-i18next"; -import useUploadedFilesStore from "@/stores/fileStore"; -jest.mock("next/router", () => ({ +import SideNav from "../Sidenav"; +jest.mock("next/navigation", () => ({ useRouter: jest.fn(), })); jest.mock("react-i18next", () => ({ @@ -88,15 +86,11 @@ describe("SideNav Component", () => { ).toHaveAttribute("href", "/SearchPage"); expect( screen.getByText(t("sideNav.repportIssue")).closest("a"), - ).toHaveAttribute( - "href", - ); + ).toHaveAttribute("href"); }); - - it("should call clearUploadedFiles when 'new inspection' is clicked", () => { - const store = useUploadedFilesStore; - const clearUploadedFilesSpy = jest.spyOn(store.getState(), "clearUploadedFiles"); + it("should navigate to '/' when 'new inspection' is clicked", () => { + const router = useRouter(); render( @@ -106,7 +100,6 @@ describe("SideNav Component", () => { const newInspectionButton = screen.getByTestId("new-inspection-button"); fireEvent.click(newInspectionButton); - expect(clearUploadedFilesSpy).toHaveBeenCalled(); + expect(router.push).toHaveBeenCalledWith("/"); }); - }); diff --git a/src/components/stepper.tsx b/src/components/stepper.tsx index 85d6610b..5c8ed530 100644 --- a/src/components/stepper.tsx +++ b/src/components/stepper.tsx @@ -4,8 +4,9 @@ import Step from "@mui/material/Step"; import StepButton from "@mui/material/StepButton"; import StepLabel from "@mui/material/StepLabel"; import Stepper from "@mui/material/Stepper"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import LoadingButton from "./LoadingButton"; export enum StepStatus { Incomplete = "incomplete", @@ -76,6 +77,12 @@ export const StepperControls: React.FC = ({ }) => { const { t } = useTranslation("labelDataValidator"); const stepsTotal = stepTitles.length; + const [loading, setLoading] = useState(false); + + const handleSubmission = () => { + setLoading(true); + submit(); + }; return ( @@ -88,16 +95,16 @@ export const StepperControls: React.FC = ({ > {t("stepper.back")} - + />