diff --git a/src/components/BaseInformationForm.tsx b/src/components/BaseInformationForm.tsx index cd453a40..f5092388 100644 --- a/src/components/BaseInformationForm.tsx +++ b/src/components/BaseInformationForm.tsx @@ -5,7 +5,8 @@ import { useEffect } from "react"; import { FormProvider, useForm, useWatch } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { VerifiedInput } from "./VerifiedFieldComponents"; -import VerifiedQuantityMultiInput from "./VerifiedQuantityMultiInput"; +import VerifiedQuantityList from "./VerifiedQuantityList"; +import VerifiedRegistrationList from "./VerifiedRegistrationList"; const BaseInformationForm: React.FC = ({ loading = false, @@ -46,13 +47,11 @@ const BaseInformationForm: React.FC = ({ path="baseInformation.name" loading={loading} /> - = ({ path="baseInformation.npk" loading={loading} /> - - - void; - onblur?: () => void; + onBlur?: () => void; verified?: boolean; } @@ -22,7 +22,7 @@ const QuantityInput = ({ disabled = false, unitRules, onFocus, - onblur, + onBlur, verified, }: QuantityInputProps) => { const { t } = useTranslation("labelDataValidator"); @@ -50,7 +50,7 @@ const QuantityInput = ({ disabled={disabled} onFocus={onFocus} onBlur={(e) => { - onblur?.(); + onBlur?.(); field.onChange(e.target.value.trim()); }} aria-label={t("quantityInput.accessibility.value")} @@ -81,13 +81,13 @@ const QuantityInput = ({ renderInput={(params) => ( { - onblur?.(); + onBlur?.(); field.onChange(e.target.value.trim()); }} error={!!error} diff --git a/src/components/RegistrationInput.tsx b/src/components/RegistrationInput.tsx new file mode 100644 index 00000000..1e3afb4a --- /dev/null +++ b/src/components/RegistrationInput.tsx @@ -0,0 +1,88 @@ +import { RegistrationType } from "@/types/types"; +import { Box, MenuItem, Select } from "@mui/material"; +import { Control, Controller, RegisterOptions } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import StyledTextField from "./StyledTextField"; + +interface RegistrationInputProps { + name: string; + control: Control; + disabled?: boolean; + typeRules?: RegisterOptions; + onFocus?: () => void; + onBlur?: () => void; + verified?: boolean; +} + +const RegistrationInput = ({ + name, + control, + disabled = false, + typeRules, + onFocus, + onBlur, + verified, +}: RegistrationInputProps) => { + const { t } = useTranslation("labelDataValidator"); + + return ( + + {/* Identifier Input Field */} + ( + { + onBlur?.(); + field.onChange(e.target.value.trim()); + }} + aria-label={t("baseInformation.fields.reg.accessibility")} + data-testid={`${name}-number-input`} + error={!!error} + helperText={error?.message ? t(error.message) : ""} + /> + )} + /> + + {/* Registration Type Selection Field */} + ( + + )} + /> + + ); +}; + +export default RegistrationInput; diff --git a/src/components/StyledDeleteButton.tsx b/src/components/StyledDeleteButton.tsx new file mode 100644 index 00000000..1fd1804b --- /dev/null +++ b/src/components/StyledDeleteButton.tsx @@ -0,0 +1,36 @@ +import DeleteIcon from "@mui/icons-material/Delete"; +import { IconButton, IconButtonProps, Tooltip } from "@mui/material"; +import React from "react"; + +interface StyledDeleteButtonProps extends IconButtonProps { + tooltip: string; + tooltipDelay?: number; + hideButton?: boolean; +} + +const StyledDeleteButton = React.forwardRef< + HTMLButtonElement, + StyledDeleteButtonProps +>( + ( + { + tooltip: tooltipTitle, + tooltipDelay: enterDelay = 1000, + hideButton, + ...props + }, + ref, + ) => { + if (hideButton) return null; + + return ( + + + + + + ); + }, +); + +export default StyledDeleteButton; diff --git a/src/components/StyledListContainer.tsx b/src/components/StyledListContainer.tsx new file mode 100644 index 00000000..9d6d6dd9 --- /dev/null +++ b/src/components/StyledListContainer.tsx @@ -0,0 +1,44 @@ +import AddIcon from "@mui/icons-material/Add"; +import { Box, BoxProps, Button } from "@mui/material"; +import React from "react"; +import { useTranslation } from "react-i18next"; + +interface StyledListContainerProps extends BoxProps { + path: string; + verified?: boolean; + onAppend: () => void; + children: React.ReactNode; +} + +const StyledListContainer: React.FC = ({ + path, + verified, + onAppend, + children, + ...boxProps +}) => { + const t = useTranslation("labelDataValidator").t; + return ( + + {children} + + {/* Add Row Button */} + + + ); +}; + +export default StyledListContainer; diff --git a/src/components/VerifiedFieldComponents.tsx b/src/components/VerifiedFieldComponents.tsx index db9a121b..f9e8e28c 100644 --- a/src/components/VerifiedFieldComponents.tsx +++ b/src/components/VerifiedFieldComponents.tsx @@ -29,6 +29,7 @@ interface VerifiedFieldWrapperProps { valuePath: string; verified: boolean; }) => ReactNode; + validate?: (callback: (valid: boolean) => void) => Promise; } export const VerifiedFieldWrapper: React.FC = ({ @@ -37,6 +38,7 @@ export const VerifiedFieldWrapper: React.FC = ({ className = "", loading = false, renderField, + validate, }) => { const { t } = useTranslation("labelDataValidator"); const { control } = useFormContext(); @@ -63,13 +65,16 @@ export const VerifiedFieldWrapper: React.FC = ({ data-testid={`verified-field-${path}`} > {renderField({ setIsFocused, control, valuePath, verified })} + + {/* Vertical Divider */} + + {/* Verified Toggle Button */} = ({ enterDelay={1000} > onChange(!value)} + onClick={() => + validate + ? validate((valid) => { + if (valid) onChange(!value); + }) + : onChange(!value) + } data-testid={`toggle-verified-btn-${verifiedPath}`} aria-label={ verified diff --git a/src/components/VerifiedListRow.tsx b/src/components/VerifiedListRow.tsx new file mode 100644 index 00000000..98c572d9 --- /dev/null +++ b/src/components/VerifiedListRow.tsx @@ -0,0 +1,42 @@ +import { Box, Divider } from "@mui/material"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import StyledDeleteButton from "./StyledDeleteButton"; + +interface VerifiedListRowProps { + verified?: boolean; + hideDelete?: boolean; + onDelete?: () => void; + isLastItem?: boolean; + children: React.ReactNode; +} + +const VerifiedListRow: React.FC = ({ + verified, + hideDelete, + onDelete, + isLastItem, + children, +}) => { + const { t } = useTranslation("labelDataValidator"); + return ( + + + {children} + + + + + + + ); +}; + +export default VerifiedListRow; diff --git a/src/components/VerifiedQuantityList.tsx b/src/components/VerifiedQuantityList.tsx new file mode 100644 index 00000000..40e2aa2c --- /dev/null +++ b/src/components/VerifiedQuantityList.tsx @@ -0,0 +1,103 @@ +import { DEFAULT_QUANTITY } from "@/types/types"; +import { Typography } from "@mui/material"; +import { useFieldArray, useFormContext, useWatch } from "react-hook-form"; +import QuantityInput from "./QuantityInput"; +import StyledListContainer from "./StyledListContainer"; +import { VerifiedFieldWrapper } from "./VerifiedFieldComponents"; +import VerifiedListRow from "./VerifiedListRow"; + +interface VerifiedQuantityListProps { + label: string; + path: string; + unitOptions: string[]; + className?: string; + loading?: boolean; +} + +const VerifiedQuantityList: React.FC = ({ + label, + path, + unitOptions, + className = "", + loading = false, +}) => { + const { control, trigger } = useFormContext(); + const { fields, append, remove } = useFieldArray({ + control, + name: `${path}.quantities`, + }); + const quantitiesPath = `${path}.quantities`; + const quantities = useWatch({ + control, + name: quantitiesPath, + }); + + const validateFields = async (callback: (valid: boolean) => void) => { + const validationResults = await Promise.all( + fields.map((_, index) => + Promise.all([ + trigger(`${quantitiesPath}.${index}.unit`), + trigger(`${quantitiesPath}.${index}.value`), + ]), + ), + ); + const allValid = validationResults.every((result) => + result.every((isValid) => isValid), + ); + callback(allValid); + }; + + const validateDuplicateUnit = (value: string) => { + const isDuplicate = + quantities.filter((item: { unit: string }) => item.unit === value) + .length > 1; + return !isDuplicate || "errors.duplicateUnit"; + }; + + return ( + + {label} + + } + path={path} + className={className} + loading={loading} + validate={validateFields} + renderField={({ setIsFocused, control, verified }) => ( + append(DEFAULT_QUANTITY)} + > + {fields.map((fieldItem, index) => ( + remove(index)} + isLastItem={index === fields.length - 1} + > + setIsFocused(true)} + onBlur={() => setIsFocused(false)} + verified={verified} + /> + + ))} + + )} + /> + ); +}; + +export default VerifiedQuantityList; diff --git a/src/components/VerifiedQuantityMultiInput.tsx b/src/components/VerifiedQuantityMultiInput.tsx deleted file mode 100644 index bd056ae8..00000000 --- a/src/components/VerifiedQuantityMultiInput.tsx +++ /dev/null @@ -1,250 +0,0 @@ -import { DEFAULT_QUANTITY } from "@/types/types"; -import AddIcon from "@mui/icons-material/Add"; -import CheckIcon from "@mui/icons-material/Check"; -import DeleteIcon from "@mui/icons-material/Delete"; -import { - Box, - Button, - Divider, - IconButton, - SvgIcon, - Tooltip, - Typography, -} from "@mui/material"; -import { useState } from "react"; -import { - Controller, - useFieldArray, - useFormContext, - useWatch, -} from "react-hook-form"; -import { useTranslation } from "react-i18next"; -import QuantityInput from "./QuantityInput"; -import StyledSkeleton from "./StyledSkeleton"; - -interface VerifiedQuantityMultiInputProps { - label: string; - path: string; - unitOptions: string[]; - className?: string; - loading?: boolean; -} - -const VerifiedQuantityMultiInput: React.FC = ({ - label, - path, - unitOptions, - className = "", - loading = false, -}) => { - const { t } = useTranslation("labelDataValidator"); - const { control, trigger } = useFormContext(); - const [isFocused, setIsFocused] = useState(false); - const [hover, setHover] = useState(false); - - const quantitiesPath = `${path}.quantities`; - const verifiedPath = `${path}.verified`; - - const { fields, append, remove } = useFieldArray({ - control, - name: quantitiesPath, - }); - - const quantities = useWatch({ - control, - name: quantitiesPath, - }); - - const verified: boolean = useWatch({ - control, - name: verifiedPath, - }); - - const toggleVerified = async ( - verified: boolean, - setVerified: (value: boolean) => void, - ) => { - const validationResults = await Promise.all( - fields.map((_, index) => - Promise.all([ - trigger(`${quantitiesPath}.${index}.unit`), - trigger(`${quantitiesPath}.${index}.value`), - ]), - ), - ); - - const allValid = validationResults.every((result) => - result.every((isValid) => isValid), - ); - - if (allValid) { - setVerified(!verified); - } - }; - - const validateDuplicateUnit = (value: string) => { - const isDuplicate = - quantities.filter((item: { unit: string }) => item.unit === value) - .length > 1; - return !isDuplicate || "errors.duplicateUnit"; - }; - - return ( - - {/* Label Section */} - - {label} - - - {loading ? ( - - ) : ( - - {/* Fields */} - - {fields.map((fieldItem, index) => { - const isLastItem = index === fields.length - 1; - - return ( - - - {/* Value & Unit Input */} - setIsFocused(true)} - onblur={() => setIsFocused(false)} - verified={verified} - /> - - {/* Delete Row Button */} - - remove(index)} - disabled={verified} - aria-label={t( - "verifiedQuantityMultiInput.accessibility.deleteRowButton", - )} - data-testid={`delete-button-${quantitiesPath}-${index}`} - > - - - - - - - ); - })} - - {/* Add Row Button */} - - - - {/* Vertical Divider */} - - - {/* Verified Toggle Button */} - ( - - toggleVerified(value, onChange)} - aria-label={t( - "verifiedQuantityMultiInput.accessibility.verifyToggleButton", - )} - data-testid={`toggle-verified-btn-${path}`} - onMouseEnter={() => setHover(true)} - onMouseLeave={() => setHover(false)} - > - {hover && verified ? ( - - - - ) : ( - - )} - - - )} - /> - - )} - - ); -}; - -export default VerifiedQuantityMultiInput; diff --git a/src/components/VerifiedRegistrationList.tsx b/src/components/VerifiedRegistrationList.tsx new file mode 100644 index 00000000..cb0a1297 --- /dev/null +++ b/src/components/VerifiedRegistrationList.tsx @@ -0,0 +1,90 @@ +import { Typography } from "@mui/material"; +import { useFieldArray, useFormContext } from "react-hook-form"; +import RegistrationInput from "./RegistrationInput"; +import StyledListContainer from "./StyledListContainer"; +import { VerifiedFieldWrapper } from "./VerifiedFieldComponents"; +import VerifiedListRow from "./VerifiedListRow"; + +interface VerifiedRegistrationListProps { + label: string; + path: string; + registrationTypes: string[]; + className?: string; + loading?: boolean; +} + +const VerifiedRegistrationList: React.FC = ({ + label, + path, + registrationTypes, + className = "", + loading = false, +}) => { + const { control, trigger } = useFormContext(); + const { fields, append, remove } = useFieldArray({ + control, + name: `${path}.registrations`, + }); + const registrationsPath = `${path}.registrations`; + + const validateFields = async (callback: (valid: boolean) => void) => { + const validationResults = await Promise.all( + fields.map((_, index) => + Promise.all([ + trigger(`${registrationsPath}.${index}.number`), + trigger(`${registrationsPath}.${index}.type`), + ]), + ), + ); + const allValid = validationResults.every((result) => + result.every((isValid) => isValid), + ); + callback(allValid); + }; + + return ( + + {label} + + } + path={path} + className={className} + loading={loading} + validate={validateFields} + renderField={({ setIsFocused, control, verified }) => ( + append({ number: "", type: "" })} + > + {fields.map((fieldItem, index) => ( + remove(index)} + isLastItem={index === fields.length - 1} + > + setIsFocused(true)} + onBlur={() => setIsFocused(false)} + verified={verified} + /> + + ))} + + )} + /> + ); +}; + +export default VerifiedRegistrationList; diff --git a/src/components/__tests__/QuantityInput.test.tsx b/src/components/__tests__/QuantityInput.test.tsx index c3b973c6..04a2c0ce 100644 --- a/src/components/__tests__/QuantityInput.test.tsx +++ b/src/components/__tests__/QuantityInput.test.tsx @@ -52,7 +52,7 @@ const Wrapper = ({ disabled={disabled} unitRules={unitRules} onFocus={onFocus} - onblur={onblur} + onBlur={onblur} />