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:
@@ -1,30 +1,32 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { PasswordIcon, UserIcon } from "@/assets/icons";
|
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 { 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 { FormErrorMessages } from "@/constants/Texts";
|
||||||
|
import { useIsMutating } from "@tanstack/react-query";
|
||||||
|
|
||||||
export default function SigninWithPassword() {
|
export default function SigninWithPassword() {
|
||||||
|
const isMutating = useIsMutating();
|
||||||
const [passwordFieldInputType, setPasswordFieldInputType] = useState<
|
const [passwordFieldInputType, setPasswordFieldInputType] = useState<
|
||||||
"password" | "text"
|
"password" | "text"
|
||||||
>("password");
|
>("password");
|
||||||
const { handleSubmit, control, reset, register } =
|
const { handleSubmit, control, reset, register } =
|
||||||
useForm<ILoginCredentials>();
|
useForm<ILoginCredentials>();
|
||||||
const { mutate, isPending } = useLoginQuery();
|
const { mutate } = useLoginQuery(reset);
|
||||||
|
|
||||||
const onSubmit: SubmitHandler<ILoginCredentials> = (data) => {
|
const onSubmit: SubmitHandler<ILoginCredentials> = (data) => {
|
||||||
mutate(data);
|
mutate(data);
|
||||||
reset();
|
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<InputGroup
|
<InputGroup
|
||||||
type="username"
|
type="username"
|
||||||
|
disabled={!!isMutating}
|
||||||
{...register("username", {
|
{...register("username", {
|
||||||
required: {
|
required: {
|
||||||
value: true,
|
value: true,
|
||||||
@@ -40,6 +42,7 @@ export default function SigninWithPassword() {
|
|||||||
|
|
||||||
<InputGroup
|
<InputGroup
|
||||||
type={passwordFieldInputType}
|
type={passwordFieldInputType}
|
||||||
|
disabled={!!isMutating}
|
||||||
{...register("password", {
|
{...register("password", {
|
||||||
required: {
|
required: {
|
||||||
value: true,
|
value: true,
|
||||||
@@ -67,7 +70,11 @@ export default function SigninWithPassword() {
|
|||||||
type="submit"
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type TagInputProps<T extends FieldValues> = {
|
|||||||
watch?: UseFormWatch<T>;
|
watch?: UseFormWatch<T>;
|
||||||
errors?: FieldErrors<T>;
|
errors?: FieldErrors<T>;
|
||||||
maxTags?: number;
|
maxTags?: number;
|
||||||
|
shouldAddWithSpace?: boolean;
|
||||||
allowDuplicates?: boolean;
|
allowDuplicates?: boolean;
|
||||||
tagValidator?: (tag: string) => boolean;
|
tagValidator?: (tag: string) => boolean;
|
||||||
} & Partial<UseFormRegisterReturn>;
|
} & Partial<UseFormRegisterReturn>;
|
||||||
@@ -43,6 +44,7 @@ const TagInput = <T extends FieldValues>({
|
|||||||
name,
|
name,
|
||||||
register,
|
register,
|
||||||
setValue,
|
setValue,
|
||||||
|
shouldAddWithSpace = false,
|
||||||
watch,
|
watch,
|
||||||
errors,
|
errors,
|
||||||
maxTags,
|
maxTags,
|
||||||
@@ -87,7 +89,11 @@ const TagInput = <T extends FieldValues>({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (e.key === "Enter" || e.key === ",") {
|
if (
|
||||||
|
e.key === "Enter" ||
|
||||||
|
e.key === "," ||
|
||||||
|
(shouldAddWithSpace && e.key === " ")
|
||||||
|
) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
addTag(inputValue);
|
addTag(inputValue);
|
||||||
} else if (e.key === "Backspace" && !inputValue && tags.length > 0) {
|
} else if (e.key === "Backspace" && !inputValue && tags.length > 0) {
|
||||||
@@ -167,7 +173,7 @@ const TagInput = <T extends FieldValues>({
|
|||||||
<input
|
<input
|
||||||
type="hidden"
|
type="hidden"
|
||||||
{...(register ? register(name) : {})}
|
{...(register ? register(name) : {})}
|
||||||
value={tags.length > 0 ? JSON.stringify(tags) : ''}
|
value={tags.length > 0 ? JSON.stringify(tags) : ""}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export function Header() {
|
|||||||
{isMobile && (
|
{isMobile && (
|
||||||
<Link href={"/"} className="ml-2 max-[430px]:hidden min-[375px]:ml-4">
|
<Link href={"/"} className="ml-2 max-[430px]:hidden min-[375px]:ml-4">
|
||||||
<Image
|
<Image
|
||||||
src={"/images/logo/logo-icon.svg"}
|
src={"/images/fav-icon.svg"}
|
||||||
width={32}
|
width={32}
|
||||||
height={32}
|
height={32}
|
||||||
alt=""
|
alt=""
|
||||||
|
|||||||
@@ -1,31 +1,31 @@
|
|||||||
import {
|
import { Dispatch, FC, SetStateAction, useEffect, useState } from "react";
|
||||||
Dispatch,
|
|
||||||
FC,
|
|
||||||
RefObject,
|
|
||||||
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 { Select } from "@/components/FormElements/select";
|
||||||
import { Building } from "@/components/Layouts/sidebar/icons";
|
import { Building } from "@/components/Layouts/sidebar/icons";
|
||||||
import { FormErrorMessages } from "@/constants/Texts";
|
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 {
|
interface IProps {
|
||||||
setCompanies: Dispatch<
|
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 [options, setOptions] = useState<ILabelValue[]>([]);
|
||||||
|
|
||||||
const { data, status, isSuccess } = useCompanyFullList();
|
const { data, status, isSuccess } = useCompanyFullList();
|
||||||
const { handleSubmit, register } =
|
const { handleSubmit, register } =
|
||||||
useForm<TAssignCustomerToCompanyCredentials>();
|
useForm<TAssignCustomerToCompanyCredentials>({
|
||||||
|
mode: "onChange",
|
||||||
|
defaultValues,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSuccess) {
|
if (isSuccess) {
|
||||||
|
|||||||
@@ -112,6 +112,9 @@ const CustomersTable = () => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCurrentCustomer(customer);
|
setCurrentCustomer(customer);
|
||||||
setIsAssignCompanyModalOpen(true);
|
setIsAssignCompanyModalOpen(true);
|
||||||
|
setSelectedCompanies({
|
||||||
|
company_ids: [customer?.companies?.[0]?.id ?? ""],
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="sr-only">Assign To Company </span>
|
<span className="sr-only">Assign To Company </span>
|
||||||
@@ -145,7 +148,10 @@ const CustomersTable = () => {
|
|||||||
isOpen={isAssignCompanyModalOpen}
|
isOpen={isAssignCompanyModalOpen}
|
||||||
onClose={() => setIsAssignCompanyModalOpen(false)}
|
onClose={() => setIsAssignCompanyModalOpen(false)}
|
||||||
>
|
>
|
||||||
<AssignToCompanyModalContent setCompanies={setSelectedCompanies} />
|
<AssignToCompanyModalContent
|
||||||
|
setCompanies={setSelectedCompanies}
|
||||||
|
defaultValues={selectedCompanies}
|
||||||
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
{isModalOpen && (
|
{isModalOpen && (
|
||||||
<ConfirmationModal
|
<ConfirmationModal
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ import { useState } from "react";
|
|||||||
const useCustomerListPresenter = () => {
|
const useCustomerListPresenter = () => {
|
||||||
const [isAssignCompanyModalOpen, setIsAssignCompanyModalOpen] =
|
const [isAssignCompanyModalOpen, setIsAssignCompanyModalOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const [selectedCompanies, setSelectedCompanies] =
|
const [selectedCompanies, setSelectedCompanies] = useState<
|
||||||
useState<TAssignCustomerToCompanyCredentials | null>(null);
|
TAssignCustomerToCompanyCredentials | undefined
|
||||||
|
>();
|
||||||
const [currentCustomer, setCurrentCustomer] = useState<TCustomer | null>(
|
const [currentCustomer, setCurrentCustomer] = useState<TCustomer | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -50,6 +51,7 @@ const useCustomerListPresenter = () => {
|
|||||||
deleteCustomer(currentCustomer.id);
|
deleteCustomer(currentCustomer.id);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const customers = data?.pages.flatMap((page) => page.data.customers) ?? [];
|
const customers = data?.pages.flatMap((page) => page.data.customers) ?? [];
|
||||||
return {
|
return {
|
||||||
allCustomers: customers,
|
allCustomers: customers,
|
||||||
|
|||||||
@@ -497,6 +497,7 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
|
|||||||
{...register("tags.cpv_codes")}
|
{...register("tags.cpv_codes")}
|
||||||
label="CPV codes"
|
label="CPV codes"
|
||||||
name="tags.cpv_codes"
|
name="tags.cpv_codes"
|
||||||
|
shouldAddWithSpace
|
||||||
watch={watch}
|
watch={watch}
|
||||||
setValue={setValue}
|
setValue={setValue}
|
||||||
placeholder="Enter Tag CPV codes"
|
placeholder="Enter Tag CPV codes"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import MultiSelect from "@/components/FormElements/MultiSelect";
|
|||||||
import { Select } from "@/components/FormElements/select";
|
import { Select } from "@/components/FormElements/select";
|
||||||
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
||||||
import FormFooter from "@/components/ui/FormFooter";
|
import FormFooter from "@/components/ui/FormFooter";
|
||||||
|
import { USERNAME_REGEX } from "@/constants/regex";
|
||||||
import { FormErrorMessages } from "@/constants/Texts";
|
import { FormErrorMessages } from "@/constants/Texts";
|
||||||
import { CreateCustomerCredentials } from "@/lib/api";
|
import { CreateCustomerCredentials } from "@/lib/api";
|
||||||
import useCreateCustomerPresenter from "./useCreateCustomerPresenter";
|
import useCreateCustomerPresenter from "./useCreateCustomerPresenter";
|
||||||
@@ -36,7 +37,7 @@ const CreateCustomer = ({ defaultValues, editMode, id }: IProps) => {
|
|||||||
message: FormErrorMessages.maxLength(30),
|
message: FormErrorMessages.maxLength(30),
|
||||||
},
|
},
|
||||||
pattern: {
|
pattern: {
|
||||||
value: /^[a-zA-Z0-9](?:[a-zA-Z0-9._]*[a-zA-Z0-9])?$/,
|
value: USERNAME_REGEX,
|
||||||
message: FormErrorMessages.invalidPattern,
|
message: FormErrorMessages.invalidPattern,
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export const PHONE_REGEX = /^[+\d]+$/;
|
export const PHONE_REGEX = /^[+\d]+$/;
|
||||||
|
export const USERNAME_REGEX = /^[a-zA-Z0-9](?:[a-zA-Z0-9._]*[a-zA-Z0-9])?$/;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
export const useLoginQuery = () => {
|
export const useLoginQuery = (callback?: () => void) => {
|
||||||
const mutationKey = useMemo(() => [API_ENDPOINTS.USER.LOGIN], []);
|
const mutationKey = useMemo(() => [API_ENDPOINTS.USER.LOGIN], []);
|
||||||
const { setAuthState } = useUser();
|
const { setAuthState } = useUser();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -18,9 +18,10 @@ export const useLoginQuery = () => {
|
|||||||
mutationKey,
|
mutationKey,
|
||||||
mutationFn: userService.login,
|
mutationFn: userService.login,
|
||||||
onSuccess: (response) => {
|
onSuccess: (response) => {
|
||||||
if (response.success) {
|
setAuthState(response.data);
|
||||||
setAuthState(response.data);
|
router.replace("/charts");
|
||||||
router.replace("/charts");
|
if (callback) {
|
||||||
|
callback();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user