feat: Add loading states and improve form handling

This commit introduces several enhancements to form components and user experience across the application.

- **Sign-in Form:**
  - Implemented a loading state using `useIsMutating` from TanStack Query.
  - The username and password fields are now disabled during the login process to prevent concurrent submissions.
  - The "Sign in" button displays a spinner while the mutation is pending, providing clear visual feedback.

- **Assign to Company Modal:**
  - Refactored the modal to use `react-hook-form` for more robust state management and validation.
  - The form submission button is now disabled while the assignment request is in progress.

- **Tag Input Component:**
  - Added a new `shouldAddWithSpace` prop to allow creating tags by pressing the space bar, increasing the component's flexibility.

- **Header:**
  - Corrected the image path for the mobile header logo to display the correct icon.
This commit is contained in:
AmirReza Jamali
2025-09-24 10:45:45 +03:30
parent 708f35cf02
commit 3801073fd7
10 changed files with 58 additions and 33 deletions
+14 -7
View File
@@ -1,30 +1,32 @@
"use client";
import { PasswordIcon, UserIcon } from "@/assets/icons";
import InputGroup from "../FormElements/InputGroup";
import { SubmitHandler, useForm } from "react-hook-form";
import { ILoginCredentials } from "@/lib/api";
import { useLoginQuery } from "@/hooks/queries";
import { useRef, useState } from "react";
import { ILoginCredentials } from "@/lib/api";
import { useState } from "react";
import { SubmitHandler, useForm } from "react-hook-form";
import InputGroup from "../FormElements/InputGroup";
import { FormErrorMessages } from "@/constants/Texts";
import { useIsMutating } from "@tanstack/react-query";
export default function SigninWithPassword() {
const isMutating = useIsMutating();
const [passwordFieldInputType, setPasswordFieldInputType] = useState<
"password" | "text"
>("password");
const { handleSubmit, control, reset, register } =
useForm<ILoginCredentials>();
const { mutate, isPending } = useLoginQuery();
const { mutate } = useLoginQuery(reset);
const onSubmit: SubmitHandler<ILoginCredentials> = (data) => {
mutate(data);
reset();
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<InputGroup
type="username"
disabled={!!isMutating}
{...register("username", {
required: {
value: true,
@@ -40,6 +42,7 @@ export default function SigninWithPassword() {
<InputGroup
type={passwordFieldInputType}
disabled={!!isMutating}
{...register("password", {
required: {
value: true,
@@ -67,7 +70,11 @@ export default function SigninWithPassword() {
type="submit"
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-primary p-4 font-medium text-white transition hover:bg-opacity-90"
>
Sign In
{isMutating ? (
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
) : (
"Sign in"
)}
</button>
</div>
</form>
@@ -28,6 +28,7 @@ type TagInputProps<T extends FieldValues> = {
watch?: UseFormWatch<T>;
errors?: FieldErrors<T>;
maxTags?: number;
shouldAddWithSpace?: boolean;
allowDuplicates?: boolean;
tagValidator?: (tag: string) => boolean;
} & Partial<UseFormRegisterReturn>;
@@ -43,6 +44,7 @@ const TagInput = <T extends FieldValues>({
name,
register,
setValue,
shouldAddWithSpace = false,
watch,
errors,
maxTags,
@@ -87,7 +89,11 @@ const TagInput = <T extends FieldValues>({
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" || e.key === ",") {
if (
e.key === "Enter" ||
e.key === "," ||
(shouldAddWithSpace && e.key === " ")
) {
e.preventDefault();
addTag(inputValue);
} else if (e.key === "Backspace" && !inputValue && tags.length > 0) {
@@ -167,7 +173,7 @@ const TagInput = <T extends FieldValues>({
<input
type="hidden"
{...(register ? register(name) : {})}
value={tags.length > 0 ? JSON.stringify(tags) : ''}
value={tags.length > 0 ? JSON.stringify(tags) : ""}
ref={ref}
/>
</div>
+1 -1
View File
@@ -24,7 +24,7 @@ export function Header() {
{isMobile && (
<Link href={"/"} className="ml-2 max-[430px]:hidden min-[375px]:ml-4">
<Image
src={"/images/logo/logo-icon.svg"}
src={"/images/fav-icon.svg"}
width={32}
height={32}
alt=""
@@ -1,31 +1,31 @@
import {
Dispatch,
FC,
RefObject,
SetStateAction,
useEffect,
useState,
} from "react";
import { Dispatch, FC, SetStateAction, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { TAssignCustomerToCompanyCredentials } from "@/lib/api";
import { useCompanyFullList } from "@/hooks/queries";
import { ILabelValue } from "@/types/shared";
import { Select } from "@/components/FormElements/select";
import { Building } from "@/components/Layouts/sidebar/icons";
import { FormErrorMessages } from "@/constants/Texts";
import { useCompanyFullList } from "@/hooks/queries";
import { TAssignCustomerToCompanyCredentials } from "@/lib/api";
import { ILabelValue } from "@/types/shared";
import { useForm } from "react-hook-form";
interface IProps {
setCompanies: Dispatch<
SetStateAction<TAssignCustomerToCompanyCredentials | null>
SetStateAction<TAssignCustomerToCompanyCredentials | undefined>
>;
defaultValues?: TAssignCustomerToCompanyCredentials;
}
const AssignToCompanyModalContent: FC<IProps> = ({ setCompanies }) => {
const AssignToCompanyModalContent: FC<IProps> = ({
setCompanies,
defaultValues,
}) => {
const [options, setOptions] = useState<ILabelValue[]>([]);
const { data, status, isSuccess } = useCompanyFullList();
const { handleSubmit, register } =
useForm<TAssignCustomerToCompanyCredentials>();
useForm<TAssignCustomerToCompanyCredentials>({
mode: "onChange",
defaultValues,
});
useEffect(() => {
if (isSuccess) {
+7 -1
View File
@@ -112,6 +112,9 @@ const CustomersTable = () => {
onClick={() => {
setCurrentCustomer(customer);
setIsAssignCompanyModalOpen(true);
setSelectedCompanies({
company_ids: [customer?.companies?.[0]?.id ?? ""],
});
}}
>
<span className="sr-only">Assign To Company </span>
@@ -145,7 +148,10 @@ const CustomersTable = () => {
isOpen={isAssignCompanyModalOpen}
onClose={() => setIsAssignCompanyModalOpen(false)}
>
<AssignToCompanyModalContent setCompanies={setSelectedCompanies} />
<AssignToCompanyModalContent
setCompanies={setSelectedCompanies}
defaultValues={selectedCompanies}
/>
</Modal>
{isModalOpen && (
<ConfirmationModal
@@ -12,8 +12,9 @@ import { useState } from "react";
const useCustomerListPresenter = () => {
const [isAssignCompanyModalOpen, setIsAssignCompanyModalOpen] =
useState(false);
const [selectedCompanies, setSelectedCompanies] =
useState<TAssignCustomerToCompanyCredentials | null>(null);
const [selectedCompanies, setSelectedCompanies] = useState<
TAssignCustomerToCompanyCredentials | undefined
>();
const [currentCustomer, setCurrentCustomer] = useState<TCustomer | null>(
null,
);
@@ -50,6 +51,7 @@ const useCustomerListPresenter = () => {
deleteCustomer(currentCustomer.id);
}
};
const customers = data?.pages.flatMap((page) => page.data.customers) ?? [];
return {
allCustomers: customers,
@@ -497,6 +497,7 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
{...register("tags.cpv_codes")}
label="CPV codes"
name="tags.cpv_codes"
shouldAddWithSpace
watch={watch}
setValue={setValue}
placeholder="Enter Tag CPV codes"
@@ -6,6 +6,7 @@ import MultiSelect from "@/components/FormElements/MultiSelect";
import { Select } from "@/components/FormElements/select";
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
import FormFooter from "@/components/ui/FormFooter";
import { USERNAME_REGEX } from "@/constants/regex";
import { FormErrorMessages } from "@/constants/Texts";
import { CreateCustomerCredentials } from "@/lib/api";
import useCreateCustomerPresenter from "./useCreateCustomerPresenter";
@@ -36,7 +37,7 @@ const CreateCustomer = ({ defaultValues, editMode, id }: IProps) => {
message: FormErrorMessages.maxLength(30),
},
pattern: {
value: /^[a-zA-Z0-9](?:[a-zA-Z0-9._]*[a-zA-Z0-9])?$/,
value: USERNAME_REGEX,
message: FormErrorMessages.invalidPattern,
},
})}
+1
View File
@@ -1 +1,2 @@
export const PHONE_REGEX = /^[+\d]+$/;
export const USERNAME_REGEX = /^[a-zA-Z0-9](?:[a-zA-Z0-9._]*[a-zA-Z0-9])?$/;
+5 -4
View File
@@ -10,7 +10,7 @@ import { useRouter } from "next/navigation";
import { useMemo } from "react";
import { toast } from "react-toastify";
export const useLoginQuery = () => {
export const useLoginQuery = (callback?: () => void) => {
const mutationKey = useMemo(() => [API_ENDPOINTS.USER.LOGIN], []);
const { setAuthState } = useUser();
const router = useRouter();
@@ -18,9 +18,10 @@ export const useLoginQuery = () => {
mutationKey,
mutationFn: userService.login,
onSuccess: (response) => {
if (response.success) {
setAuthState(response.data);
router.replace("/charts");
setAuthState(response.data);
router.replace("/charts");
if (callback) {
callback();
}
},
});