+ {/* 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")}
-
+ />