From 3b2950b1ef1ceadd870f2380d6f37df3c7472042 Mon Sep 17 00:00:00 2001 From: "K. Allagbe" Date: Thu, 23 Jan 2025 19:30:30 -0700 Subject: [PATCH] issue #393: debounce form fields --- package-lock.json | 16 ++++++ package.json | 1 + src/app/label-data-validation/page.tsx | 7 +-- src/components/BaseInformationForm.tsx | 17 +++---- src/components/CautionsForm.tsx | 17 +++---- src/components/GuaranteedAnalysisForm.tsx | 15 +++--- src/components/ImageViewer.tsx | 19 +------ src/components/IngredientsForm.tsx | 17 +++---- src/components/InstructionsForm.tsx | 17 +++---- src/components/LabelDataValidator.tsx | 3 +- src/components/OrganizationsForm.tsx | 19 ++++--- .../__tests__/LabelDataValidator.test.tsx | 49 ++++++++++++++++--- .../__tests__/OrganizationsForm.test.tsx | 23 +++++++-- src/utils/client/useDebouncedSave.ts | 20 ++++++++ 14 files changed, 150 insertions(+), 90 deletions(-) create mode 100644 src/utils/client/useDebouncedSave.ts diff --git a/package-lock.json b/package-lock.json index ea6cfc7c..9a4e2346 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@mui/icons-material": "^6.4.1", "@mui/material": "^6.4.1", "@mui/material-nextjs": "^6.3.1", + "@types/lodash.debounce": "^4.0.9", "axios": "^1.7.9", "dotenv": "^16.4.7", "i18next": "^24.2.1", @@ -2781,6 +2782,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.14", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.14.tgz", + "integrity": "sha512-jsxagdikDiDBeIRaPYtArcT8my4tN1og7MtMRquFT3XNA6axxyHDRUemqDz/taRDdOUn0GnGHRCuff4q48sW9A==", + "license": "MIT" + }, + "node_modules/@types/lodash.debounce": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/lodash.debounce/-/lodash.debounce-4.0.9.tgz", + "integrity": "sha512-Ma5JcgTREwpLRwMM+XwBR7DaWe96nC38uCBDFKZWbNKD+osjVzdpnUSwBcqCptrp16sSOLBAUb50Car5I0TCsQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/node": { "version": "22.10.9", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.9.tgz", diff --git a/package.json b/package.json index 2674fe6b..fccdb04d 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@mui/icons-material": "^6.4.1", "@mui/material": "^6.4.1", "@mui/material-nextjs": "^6.3.1", + "@types/lodash.debounce": "^4.0.9", "axios": "^1.7.9", "dotenv": "^16.4.7", "i18next": "^24.2.1", diff --git a/src/app/label-data-validation/page.tsx b/src/app/label-data-validation/page.tsx index 9b03c17a..49f4603e 100644 --- a/src/app/label-data-validation/page.tsx +++ b/src/app/label-data-validation/page.tsx @@ -96,14 +96,9 @@ function LabelDataValidationPage() { }; }, [uploadedFiles, showAlert, router, storedLabelData, setLabelData]); - const getFiles = () => { - console.log("log uploadedFiles:", uploadedFiles); - return uploadedFiles.map((file) => file.getFile()); - }; - return ( file.getFile())} labelData={labelData} setLabelData={setLabelData} loading={loading} diff --git a/src/components/BaseInformationForm.tsx b/src/components/BaseInformationForm.tsx index 5f86a96d..cd453a40 100644 --- a/src/components/BaseInformationForm.tsx +++ b/src/components/BaseInformationForm.tsx @@ -1,4 +1,5 @@ import { FormComponentProps, LabelData, UNITS } from "@/types/types"; +import useDebouncedSave from "@/utils/client/useDebouncedSave"; import { Box } from "@mui/material"; import { useEffect } from "react"; import { FormProvider, useForm, useWatch } from "react-hook-form"; @@ -12,30 +13,28 @@ const BaseInformationForm: React.FC = ({ setLabelData, }) => { const { t } = useTranslation("labelDataValidator"); + const sectionName = "baseInformation"; const methods = useForm({ defaultValues: labelData, }); const watchedBaseInformation = useWatch({ control: methods.control, - name: "baseInformation", + name: sectionName, }); + const save = useDebouncedSave(setLabelData); + useEffect(() => { const currentValues = methods.getValues(); if (JSON.stringify(currentValues) !== JSON.stringify(labelData)) { methods.reset(labelData); } }, [labelData, methods]); - + useEffect(() => { - if (watchedBaseInformation) { - setLabelData((prevLabelData) => ({ - ...prevLabelData, - baseInformation: watchedBaseInformation, - })); - } - }, [watchedBaseInformation, setLabelData]); + save(sectionName, watchedBaseInformation); + }, [watchedBaseInformation, save]); return ( diff --git a/src/components/CautionsForm.tsx b/src/components/CautionsForm.tsx index 3890d552..4212b68e 100644 --- a/src/components/CautionsForm.tsx +++ b/src/components/CautionsForm.tsx @@ -1,4 +1,5 @@ import { FormComponentProps, LabelData } from "@/types/types"; +import useDebouncedSave from "@/utils/client/useDebouncedSave"; import { Box } from "@mui/material"; import { useEffect } from "react"; import { FormProvider, useForm, useWatch } from "react-hook-form"; @@ -12,12 +13,15 @@ const CautionsForm: React.FC = ({ const methods = useForm({ defaultValues: labelData, }); + const sectionName = "cautions"; const watchedCautions = useWatch({ control: methods.control, - name: "cautions", + name: sectionName, }); + const save = useDebouncedSave(setLabelData); + useEffect(() => { const currentValues = methods.getValues(); if (JSON.stringify(currentValues) !== JSON.stringify(labelData)) { @@ -26,18 +30,13 @@ const CautionsForm: React.FC = ({ }, [labelData, methods]); useEffect(() => { - if (watchedCautions) { - setLabelData((prevLabelData) => ({ - ...prevLabelData, - cautions: watchedCautions, - })); - } - }, [watchedCautions, setLabelData]); + save(sectionName, watchedCautions); + }, [watchedCautions, save]); return ( - + ); diff --git a/src/components/GuaranteedAnalysisForm.tsx b/src/components/GuaranteedAnalysisForm.tsx index 0786d122..437718c0 100644 --- a/src/components/GuaranteedAnalysisForm.tsx +++ b/src/components/GuaranteedAnalysisForm.tsx @@ -1,4 +1,5 @@ import { FormComponentProps, LabelData, UNITS } from "@/types/types"; +import useDebouncedSave from "@/utils/client/useDebouncedSave"; import { Box, Typography } from "@mui/material"; import { useEffect } from "react"; import { FormProvider, useForm, useWatch } from "react-hook-form"; @@ -15,12 +16,15 @@ const GuaranteedAnalysisForm: React.FC = ({ const methods = useForm({ defaultValues: labelData, }); + const sectionName = "guaranteedAnalysis"; const watchedGuaranteedAnalysis = useWatch({ control: methods.control, - name: "guaranteedAnalysis", + name: sectionName, }); + const save = useDebouncedSave(setLabelData); + useEffect(() => { const currentValues = methods.getValues(); if (JSON.stringify(currentValues) !== JSON.stringify(labelData)) { @@ -29,13 +33,8 @@ const GuaranteedAnalysisForm: React.FC = ({ }, [labelData, methods]); useEffect(() => { - if (watchedGuaranteedAnalysis) { - setLabelData((prevLabelData) => ({ - ...prevLabelData, - guaranteedAnalysis: watchedGuaranteedAnalysis, - })); - } - }, [watchedGuaranteedAnalysis, setLabelData]); + save(sectionName, watchedGuaranteedAnalysis); + }, [watchedGuaranteedAnalysis, save]); return ( diff --git a/src/components/ImageViewer.tsx b/src/components/ImageViewer.tsx index 9407a279..44b48d0d 100644 --- a/src/components/ImageViewer.tsx +++ b/src/components/ImageViewer.tsx @@ -6,7 +6,7 @@ import ZoomOutIcon from "@mui/icons-material/ZoomOut"; import { Box, Button } from "@mui/material"; import Tooltip from "@mui/material/Tooltip"; import Image from "next/image"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import type { ReactZoomPanPinchRef } from "react-zoom-pan-pinch"; import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch"; import "swiper/css"; @@ -19,27 +19,12 @@ interface ImageViewerProps { } const ImageViewer: React.FC = ({ imageFiles }) => { - const [imageUrls, setImageUrls] = useState([]); const [swiperInstance, setSwiperInstance] = useState( null, ); const [zoomRefs, setZoomRefs] = useState([]); const [activeIndex, setActiveIndex] = useState(0); - - useEffect(() => { - const urls = imageFiles.map((file) => URL.createObjectURL(file)); - setImageUrls(urls); - setZoomRefs((prevRefs) => - Array.from({ length: urls.length }, (_, i) => prevRefs[i] || null), - ); - return () => { - urls.forEach((url) => URL.revokeObjectURL(url)); - }; - }, [imageFiles, setZoomRefs, setImageUrls, setActiveIndex]); - - useEffect(() => { - zoomRefs.forEach((ref) => ref?.resetTransform()); - }, [imageFiles, zoomRefs]); + const imageUrls = imageFiles.map((file) => URL.createObjectURL(file)); const handleInit = (index: number, ref: ReactZoomPanPinchRef) => { setZoomRefs((prevRefs) => { diff --git a/src/components/IngredientsForm.tsx b/src/components/IngredientsForm.tsx index ca11d034..7d72d3e1 100644 --- a/src/components/IngredientsForm.tsx +++ b/src/components/IngredientsForm.tsx @@ -1,4 +1,5 @@ import { FormComponentProps, LabelData, UNITS } from "@/types/types"; +import useDebouncedSave from "@/utils/client/useDebouncedSave"; import { Box } from "@mui/material"; import { useEffect } from "react"; import { FormProvider, useForm, useWatch } from "react-hook-form"; @@ -12,14 +13,17 @@ function IngredientsForm({ const methods = useForm({ defaultValues: labelData, }); + const sectionName = "ingredients"; const { control } = methods; const watchedIngredients = useWatch({ control, - name: "ingredients", + name: sectionName, }); + const save = useDebouncedSave(setLabelData); + useEffect(() => { const currentValues = methods.getValues(); if (JSON.stringify(currentValues) !== JSON.stringify(labelData)) { @@ -28,19 +32,14 @@ function IngredientsForm({ }, [labelData, methods]); useEffect(() => { - if (watchedIngredients) { - setLabelData((prevLabelData) => ({ - ...prevLabelData, - ingredients: watchedIngredients, - })); - } - }, [watchedIngredients, setLabelData]); + save(sectionName, watchedIngredients); + }, [watchedIngredients, save]); return ( = ({ const methods = useForm({ defaultValues: labelData, }); + const sectionName = "instructions"; const watchedInstructions = useWatch({ control: methods.control, - name: "instructions", + name: sectionName, }); + const save = useDebouncedSave(setLabelData); + useEffect(() => { const currentValues = methods.getValues(); if (JSON.stringify(currentValues) !== JSON.stringify(labelData)) { @@ -26,18 +30,13 @@ const InstructionsForm: React.FC = ({ }, [labelData, methods]); useEffect(() => { - if (watchedInstructions) { - setLabelData((prevLabelData) => ({ - ...prevLabelData, - instructions: watchedInstructions, - })); - } - }, [watchedInstructions, setLabelData]); + save(sectionName, watchedInstructions); + }, [watchedInstructions, save]); return ( - + ); diff --git a/src/components/LabelDataValidator.tsx b/src/components/LabelDataValidator.tsx index dabc8a7c..16f9e45d 100644 --- a/src/components/LabelDataValidator.tsx +++ b/src/components/LabelDataValidator.tsx @@ -38,7 +38,6 @@ function LabelDataValidator({ setLabelData, }: LabelDataValidatorProps) { const { t } = useTranslation("labelDataValidator"); - const imageFiles = files; const { isDownXs, isBetweenXsSm, isBetweenSmMd, isBetweenMdLg } = useBreakpoints(); const isLgOrBelow = @@ -199,7 +198,7 @@ function LabelDataValidator({ className="flex h-[500px] md:h-[720px] lg:size-full justify-center min-w-0 " data-testid="image-viewer-container" > - + {isLgOrBelow && ( diff --git a/src/components/OrganizationsForm.tsx b/src/components/OrganizationsForm.tsx index 88259068..3e64df74 100644 --- a/src/components/OrganizationsForm.tsx +++ b/src/components/OrganizationsForm.tsx @@ -5,6 +5,7 @@ import { Organization, } from "@/types/types"; import { checkFieldRecord } from "@/utils/client/fieldValidation"; +import useDebouncedSave from "@/utils/client/useDebouncedSave"; import AddIcon from "@mui/icons-material/Add"; import DeleteIcon from "@mui/icons-material/Delete"; import DoneAllIcon from "@mui/icons-material/DoneAll"; @@ -34,17 +35,20 @@ const OrganizationsForm: React.FC = ({ }); const { control, setValue } = methods; + const sectionName = "organizations"; const { fields, append, remove } = useFieldArray({ control, - name: "organizations", + name: sectionName, }); const watchedOrganizations = useWatch({ control, - name: "organizations", + name: sectionName, }); + const save = useDebouncedSave(setLabelData); + useEffect(() => { const currentValues = methods.getValues(); if (JSON.stringify(currentValues) !== JSON.stringify(labelData)) { @@ -53,19 +57,14 @@ const OrganizationsForm: React.FC = ({ }, [labelData, methods]); useEffect(() => { - if (watchedOrganizations) { - setLabelData((prevLabelData) => ({ - ...prevLabelData, - organizations: watchedOrganizations, - })); - } - }, [watchedOrganizations, setLabelData]); + save(sectionName, watchedOrganizations); + }, [watchedOrganizations, save]); const setAllVerified = useCallback( (orgIndex: number, verified: boolean) => { fieldNames.forEach((fieldName) => { const fieldPath = - `organizations.${orgIndex}.${fieldName}.verified` as FieldPath; + `${sectionName}.${orgIndex}.${fieldName}.verified` as FieldPath; setValue(fieldPath, verified, { shouldValidate: true, shouldDirty: true, diff --git a/src/components/__tests__/LabelDataValidator.test.tsx b/src/components/__tests__/LabelDataValidator.test.tsx index 4c3b9981..c0829989 100644 --- a/src/components/__tests__/LabelDataValidator.test.tsx +++ b/src/components/__tests__/LabelDataValidator.test.tsx @@ -1,9 +1,9 @@ import LabelDataValidator from "@/components/LabelDataValidator"; +import useLabelDataStore from "@/stores/labelDataStore"; import { DEFAULT_LABEL_DATA } from "@/types/types"; import { VERIFIED_LABEL_DATA } from "@/utils/client/constants"; import { act, fireEvent, render, screen } from "@testing-library/react"; -import { useState } from "react"; -import useLabelDataStore from "@/stores/labelDataStore"; +import { JSX, useState } from "react"; jest.mock("@/components/ImageViewer", () => ({ __esModule: true, @@ -148,7 +148,7 @@ describe("LabelDataValidator Functionality", () => { }); describe("LabelDataValidator and Forms Integration", () => { - it("marks the Organizations step as Completed or Incomplete when fields are Verified", () => { + it("marks the Organizations step as Completed or Incomplete when fields are Verified", async () => { render( {({ labelData, setLabelData }) => ( @@ -173,12 +173,17 @@ describe("LabelDataValidator and Forms Integration", () => { const verifyAllButton = screen.getByTestId("verify-all-btn-0"); fireEvent.click(verifyAllButton); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 350)); + }); expect(targetSpan).toHaveClass("Mui-completed"); fireEvent.click( screen.getByTestId("verified-icon-organizations.0.address.verified"), ); - + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 350)); + }); expect(targetSpan).not.toHaveClass("Mui-completed"); }); @@ -219,12 +224,17 @@ describe("LabelDataValidator and Forms Integration", () => { }); } + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 350)); + }); expect(targetSpan).toHaveClass("Mui-completed"); await act(async () => { fireEvent.click(verifyButtons[0]); }); - + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 350)); + }); expect(targetSpan).not.toHaveClass("Mui-completed"); }); @@ -263,12 +273,19 @@ describe("LabelDataValidator and Forms Integration", () => { }); } + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 350)); + }); + expect(targetSpan).toHaveClass("Mui-completed"); await act(async () => { fireEvent.click(verifyButtons[0]); }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 350)); + }); expect(targetSpan).not.toHaveClass("Mui-completed"); }); @@ -309,12 +326,18 @@ describe("LabelDataValidator and Forms Integration", () => { }); } + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 350)); + }); expect(targetSpan).toHaveClass("Mui-completed"); await act(async () => { fireEvent.click(verifyButtons[0]); }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 350)); + }); expect(targetSpan).not.toHaveClass("Mui-completed"); }); @@ -361,6 +384,9 @@ describe("LabelDataValidator and Forms Integration", () => { .forEach((btn) => fireEvent.click(btn)); }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 350)); + }); expect(targetSpan).toHaveClass("Mui-completed"); await act(async () => { @@ -369,6 +395,9 @@ describe("LabelDataValidator and Forms Integration", () => { ); }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 350)); + }); expect(targetSpan).not.toHaveClass("Mui-completed"); }); @@ -409,12 +438,18 @@ describe("LabelDataValidator and Forms Integration", () => { }); } + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 350)); + }); expect(targetSpan).toHaveClass("Mui-completed"); await act(async () => { fireEvent.click(verifyButtons[0]); }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 350)); + }); expect(targetSpan).not.toHaveClass("Mui-completed"); }); @@ -437,7 +472,9 @@ describe("LabelDataValidator and Forms Integration", () => { }); expect(mockedRouterPush).toHaveBeenCalledTimes(1); - expect(useLabelDataStore.getState().labelData).toStrictEqual(VERIFIED_LABEL_DATA); + expect(useLabelDataStore.getState().labelData).toStrictEqual( + VERIFIED_LABEL_DATA, + ); expect(mockedRouterPush).toHaveBeenCalledWith("/label-data-confirmation"); }); }); diff --git a/src/components/__tests__/OrganizationsForm.test.tsx b/src/components/__tests__/OrganizationsForm.test.tsx index 2fd3d36c..7cd33481 100644 --- a/src/components/__tests__/OrganizationsForm.test.tsx +++ b/src/components/__tests__/OrganizationsForm.test.tsx @@ -6,7 +6,7 @@ import { Organization, } from "@/types/types"; import { fireEvent, render, screen } from "@testing-library/react"; -import { useEffect, useState } from "react"; +import { act, useEffect, useState } from "react"; import { FormProvider, useForm } from "react-hook-form"; import OrganizationsForm from "../OrganizationsForm"; @@ -40,6 +40,7 @@ describe("OrganizationsForm Rendering", () => { render( { expect(screen.queryAllByTestId(/organization-\d+/)).toHaveLength(0); }); - it("should update watched organizations in state when a field is updated", () => { + it("should update watched organizations in state when a field is updated", async () => { const mockStateChange = jest.fn(); render( @@ -124,6 +125,9 @@ describe("OrganizationsForm Functionality", () => { fireEvent.change(input!, { target: { value: "Updated Address" } }); expect(input).toHaveValue("Updated Address"); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 350)); + }); expect(mockStateChange).toHaveBeenCalledWith( expect.objectContaining({ organizations: [ @@ -137,7 +141,7 @@ describe("OrganizationsForm Functionality", () => { ); }); - it("should update the organization field verification when the Verified button is clicked", () => { + it("should update the organization field verification when the Verified button is clicked", async () => { const mockStateChange = jest.fn(); render( @@ -154,6 +158,9 @@ describe("OrganizationsForm Functionality", () => { fireEvent.click(verifyButton); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 350)); + }); expect(mockStateChange).toHaveBeenCalledWith( expect.objectContaining({ organizations: [ @@ -167,7 +174,7 @@ describe("OrganizationsForm Functionality", () => { ); }); - it("should mark all fields as Verified and update the data when Mark All Verified button is clicked", () => { + it("should mark all fields as Verified and update the data when Mark All Verified button is clicked", async () => { const mockStateChange = jest.fn(); render( @@ -182,6 +189,9 @@ describe("OrganizationsForm Functionality", () => { fireEvent.click(verifyAllButton); expect(verifyAllButton).toBeDisabled(); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 350)); + }); expect(mockStateChange).toHaveBeenCalledWith( expect.objectContaining({ organizations: [ @@ -237,7 +247,7 @@ describe("OrganizationsForm Functionality", () => { expect(verifyAllButton).toBeDisabled(); }); - it("should mark all fields as Unverified and update the data when Mark All Unverified button is clicked", () => { + it("should mark all fields as Unverified and update the data when Mark All Unverified button is clicked", async () => { const mockStateChange = jest.fn(); const partiallyVerifiedOrg = { @@ -275,6 +285,9 @@ describe("OrganizationsForm Functionality", () => { fireEvent.click(unverifyAllButton); expect(unverifyAllButton).toBeDisabled(); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 350)); + }); expect(mockStateChange).toHaveBeenCalledWith( expect.objectContaining({ organizations: [ diff --git a/src/utils/client/useDebouncedSave.ts b/src/utils/client/useDebouncedSave.ts new file mode 100644 index 00000000..abb7ae95 --- /dev/null +++ b/src/utils/client/useDebouncedSave.ts @@ -0,0 +1,20 @@ +import { LabelData } from "@/types/types"; +import { debounce } from "lodash"; +import { useRef } from "react"; + +function useDebouncedSave( + setLabelData: React.Dispatch>, + delay: number = 300, +) { + return useRef( + debounce((key: keyof LabelData, value: LabelData[keyof LabelData]) => { + if (value) + setLabelData((prevData) => ({ + ...prevData, + [key]: value, + })); + }, delay), + ).current; +} + +export default useDebouncedSave;