feat(form): Add clearable functionality to Select component

This commit introduces a new `clearable` prop to the `Select` component, allowing users to easily reset its value.

When the `clearable` prop is set to true, a clear button (an 'X' icon) is displayed when an option is selected. Clicking this button resets the select input to its initial placeholder state and triggers an optional `onClear` callback.

To support this, the component's internal state management was refactored to control the input's value.

Additionally, the `AdminListFilters` component has been updated to:
- Utilize the new clearable select for the status filter.
- Consolidate multiple search fields (email, username, full_name) into a single generic "search" input.
- Add explicit "Reset" and "Apply" buttons for better filter management.
This commit is contained in:
AmirReza Jamali
2025-10-13 12:26:56 +03:30
parent 5b73389d64
commit f84c21f829
8 changed files with 257 additions and 116 deletions
+38 -1
View File
@@ -22,6 +22,8 @@ type PropsType<T extends FieldValues> = {
register?: UseFormRegister<T>; register?: UseFormRegister<T>;
errors?: FieldErrors<T>; errors?: FieldErrors<T>;
required?: boolean; required?: boolean;
clearable?: boolean;
onClear?: () => void;
} & ( } & (
| { placeholder?: string; defaultValue: string } | { placeholder?: string; defaultValue: string }
| { placeholder: string; defaultValue?: string } | { placeholder: string; defaultValue?: string }
@@ -42,13 +44,22 @@ export function Select<T extends FieldValues>({
onChange, onChange,
onBlur, onBlur,
ref, ref,
clearable = false,
onClear,
...props ...props
}: PropsType<T>) { }: PropsType<T>) {
const id = useId(); const id = useId();
const [isOptionSelected, setIsOptionSelected] = useState(!!defaultValue); const [isOptionSelected, setIsOptionSelected] = useState(!!defaultValue);
const [value, setValue] = useState(defaultValue || "");
const error = errors && errors[name]; const error = errors && errors[name];
const handleClear = () => {
setValue("");
setIsOptionSelected(false);
onClear?.();
};
return ( return (
<div className={cn("space-y-3", className)}> <div className={cn("space-y-3", className)}>
<label <label
@@ -70,8 +81,9 @@ export function Select<T extends FieldValues>({
id={id} id={id}
name={name} name={name}
ref={ref} ref={ref}
defaultValue={defaultValue || ""} value={value}
onChange={(e) => { onChange={(e) => {
setValue(e.target.value);
setIsOptionSelected(!!e.target.value); setIsOptionSelected(!!e.target.value);
onChange?.(e); onChange?.(e);
}} }}
@@ -81,6 +93,7 @@ export function Select<T extends FieldValues>({
isOptionSelected && "text-dark dark:text-white", isOptionSelected && "text-dark dark:text-white",
error && "border-red focus:border-red dark:border-red", error && "border-red focus:border-red dark:border-red",
prefixIcon && "pl-11.5", prefixIcon && "pl-11.5",
clearable && value && "pr-20",
)} )}
> >
{placeholder && ( {placeholder && (
@@ -96,6 +109,30 @@ export function Select<T extends FieldValues>({
))} ))}
</select> </select>
{clearable && value && (
<button
type="button"
onClick={handleClear}
className="absolute right-10 top-1/2 -translate-y-1/2 text-dark-5 hover:text-dark dark:text-dark-6 dark:hover:text-white"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 4L4 12M4 4L12 12"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
)}
<ChevronUpIcon className="pointer-events-none absolute right-4 top-1/2 -translate-y-1/2 rotate-180" /> <ChevronUpIcon className="pointer-events-none absolute right-4 top-1/2 -translate-y-1/2 rotate-180" />
</div> </div>
@@ -1,57 +1,95 @@
import InputGroup from "@/components/FormElements/InputGroup"; import InputGroup from "@/components/FormElements/InputGroup";
import { Select } from "@/components/FormElements/select"; import { Select } from "@/components/FormElements/select";
import { AdminRoles, NotificationChannels } from "@/constants/enums"; import { AdminRoles, AdminStatus } from "@/constants/enums";
import { getEnumAsArray } from "@/utils/shared"; import { getEnumAsArray } from "@/utils/shared";
import useAdminListFilterPresenter from "./useAdminListFilterPresenter"; import {
FieldValues,
UseFormHandleSubmit,
UseFormRegister,
} from "react-hook-form";
interface IProps {} interface AdminListFiltersProps {
filterRegister: UseFormRegister<FieldValues>;
setIsFilterModalOpen: (isOpen: boolean) => void;
isMutating: number;
handleFilterSubmit: UseFormHandleSubmit<FieldValues>;
search: (data: any) => void;
}
const AdminListFilters = ({}: IProps) => { const AdminListFilters = ({
const { register } = useAdminListFilterPresenter(); filterRegister,
setIsFilterModalOpen,
isMutating,
handleFilterSubmit,
search,
}: AdminListFiltersProps) => {
return ( return (
<form className="grid !min-w-full grid-cols-3 gap-4"> <form
className="grid !min-w-full grid-cols-3 gap-4"
onSubmit={handleFilterSubmit(search)}
>
<Select <Select
{...register("method")} {...filterRegister("status")}
items={getEnumAsArray(NotificationChannels)} items={getEnumAsArray(AdminStatus)}
label="Method"
name="method"
placeholder="Method"
/>
<Select
{...register("status")}
items={getEnumAsArray(NotificationChannels)}
label="status" label="status"
name="status" name="status"
placeholder="Status" placeholder="Status"
clearable
/> />
<InputGroup <InputGroup
{...register("email")} {...filterRegister("search")}
name="search"
label="search"
placeholder="search"
type="search"
/>
{/* <InputGroup
{...filterRegister("email")}
name="email" name="email"
label="email" label="email"
placeholder="email" placeholder="email"
type="email" type="email"
/> /> */}
<Select <Select
{...register("role")} {...filterRegister("role")}
name="role" name="role"
items={getEnumAsArray(AdminRoles)} items={getEnumAsArray(AdminRoles)}
label="Role" label="Role"
placeholder="Role" placeholder="Role"
/> />
<InputGroup {/* <InputGroup
{...register("username")} {...filterRegister("username")}
label="username" label="username"
name="username" name="username"
placeholder="username" placeholder="username"
type="text" type="text"
/> /> */}
<InputGroup {/* <InputGroup
{...register("full_name")} {...filterRegister("full_name")}
label="full name" label="full name"
name="full_name" name="full_name"
placeholder="full name" placeholder="full name"
type="text" type="text"
/> /> */}
<div className="flex gap-4">
<button
type="submit"
className="mt-6 flex w-fit justify-center rounded-lg bg-primary p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
>
{isMutating ? (
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
) : (
<span>Search</span>
)}
</button>
<button
type="button"
className="mt-6 flex w-fit justify-center rounded-lg bg-error p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
onClick={() => setIsFilterModalOpen(false)}
>
Close
</button>
</div>
</form> </form>
); );
}; };
+29 -10
View File
@@ -39,6 +39,10 @@ const AdminsTable = () => {
currentUser, currentUser,
setStatus, setStatus,
status, status,
handleFilterSubmit,
search,
filterRegister,
isMutating,
} = useAdminsPresenter(); } = useAdminsPresenter();
return ( return (
@@ -59,29 +63,36 @@ const AdminsTable = () => {
</TableHeader> </TableHeader>
{isPending && <TableSkeleton column={columns.length} />} {isPending && <TableSkeleton column={columns.length} />}
<TableBody> <TableBody>
{allUsers.map((item, index) => { {!allUsers.length ? (
<TableRow>
<TableCell>
<h2>No user</h2>
</TableCell>
</TableRow>
) : (
allUsers.map((item, index) => {
return ( return (
<TableRow <TableRow
key={item.id} key={item?.id || index}
className="odd:bg-gray-2 dark:odd:bg-gray-7" className="odd:bg-gray-2 dark:odd:bg-gray-7"
> >
<TableCell className="text-start" colSpan={100}> <TableCell className="text-start" colSpan={100}>
{index + 1} {index + 1}
</TableCell> </TableCell>
<TableCell className="text-start" colSpan={100}> <TableCell className="text-start" colSpan={100}>
{item.full_name} {item?.full_name}
</TableCell> </TableCell>
<TableCell className="text-start" colSpan={100}> <TableCell className="text-start" colSpan={100}>
{item.email} {item?.email}
</TableCell> </TableCell>
<TableCell className="text-start" colSpan={100}> <TableCell className="text-start" colSpan={100}>
{item.role} {item?.role}
</TableCell> </TableCell>
<TableCell className="text-start" colSpan={100}> <TableCell className="text-start" colSpan={100}>
{item.username} {item?.username}
</TableCell> </TableCell>
<TableCell className="text-start" colSpan={100}> <TableCell className="text-start" colSpan={100}>
<Status status={item.status ?? ""}>{item.status}</Status> <Status status={item?.status ?? ""}>{item?.status}</Status>
</TableCell> </TableCell>
<TableCell className="text-start xl:pr-7.5"> <TableCell className="text-start xl:pr-7.5">
@@ -131,7 +142,8 @@ const AdminsTable = () => {
</TableCell> </TableCell>
</TableRow> </TableRow>
); );
})} })
)}
</TableBody> </TableBody>
</Table> </Table>
<Modal <Modal
@@ -150,9 +162,16 @@ const AdminsTable = () => {
<Modal <Modal
isOpen={isFilterModalOpen} isOpen={isFilterModalOpen}
onClose={() => setIsFilterModalOpen(false)} onClose={() => setIsFilterModalOpen(false)}
classNames="w-full" classNames="w-3/4"
showButtons={false}
> >
<AdminListFilters /> <AdminListFilters
filterRegister={filterRegister}
handleFilterSubmit={handleFilterSubmit}
isMutating={isMutating as number}
search={search}
setIsFilterModalOpen={setIsFilterModalOpen}
/>
</Modal> </Modal>
{isModalOpen && ( {isModalOpen && (
<ConfirmationModal <ConfirmationModal
@@ -1,10 +0,0 @@
import { useForm } from "react-hook-form";
const useAdminListFilterPresenter = () => {
const { register } = useForm();
return {
register,
};
};
export default useAdminListFilterPresenter;
@@ -2,12 +2,14 @@
import { apiDefaultParams } from "@/constants/Api_Params"; import { apiDefaultParams } from "@/constants/Api_Params";
import { AdminStatus } from "@/constants/enums"; import { AdminStatus } from "@/constants/enums";
import { deleteEmptyKeys } from "@/utils/shared";
import { import {
useAdminListInfiniteQuery,
useChangeAdminStatus, useChangeAdminStatus,
useDeleteAdmin, useDeleteAdmin,
useGetAdminListQuery,
} from "@/hooks/queries"; } from "@/hooks/queries";
import { IUser, TChangeAdminStatusCredentials } from "@/lib/api"; import { API_ENDPOINTS, IUser, TChangeAdminStatusCredentials } from "@/lib/api";
import { useIsMutating, useQueryClient } from "@tanstack/react-query";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -20,7 +22,12 @@ export const useAdminsPresenter = () => {
const [status, setStatus] = useState<string>( const [status, setStatus] = useState<string>(
currentUser?.status ?? AdminStatus.ACTIVE, currentUser?.status ?? AdminStatus.ACTIVE,
); );
const isMutating = useIsMutating();
const router = useRouter(); const router = useRouter();
const { register: filterRegister, handleSubmit: handleFilterSubmit } =
useForm();
const queryClient = useQueryClient();
const { const {
register, register,
formState: { errors }, formState: { errors },
@@ -33,9 +40,11 @@ export const useAdminsPresenter = () => {
}); });
const [params, setParams] = useState<Record<string, any>>({ const [params, setParams] = useState<Record<string, any>>({
...apiDefaultParams, ...apiDefaultParams,
offset: 0,
limit: 10,
}); });
const pathName = usePathname(); const pathName = usePathname();
const { data, isPending } = useAdminListInfiniteQuery(params); const { data, isPending } = useGetAdminListQuery(params);
const { mutate: deleteAdmin } = useDeleteAdmin(() => { const { mutate: deleteAdmin } = useDeleteAdmin(() => {
setIsModalOpen(false); setIsModalOpen(false);
}); });
@@ -51,7 +60,16 @@ export const useAdminsPresenter = () => {
setIsChangeStatusModalOpen(false); setIsChangeStatusModalOpen(false);
}, },
}); });
const allUsers = data?.pages.flatMap((page) => page.data.users) ?? [];
const search = (data: any) => {
setParams((prevParams) => {
const newParams = { ...prevParams, ...data, offset: 0 };
return deleteEmptyKeys(newParams);
});
setIsFilterModalOpen(false);
};
const allUsers = data?.data?.users ?? [];
const metadata = data?.meta;
const columns = [ const columns = [
"row", "row",
"full name", "full name",
@@ -63,11 +81,13 @@ export const useAdminsPresenter = () => {
]; ];
return { return {
allUsers, allUsers,
metadata,
columns, columns,
isPending, isPending,
onConfirmDelete, onConfirmDelete,
isModalOpen, isModalOpen,
setIsModalOpen, setIsModalOpen,
isMutating,
router, router,
setCurrentUser, setCurrentUser,
pathName, pathName,
@@ -82,5 +102,9 @@ export const useAdminsPresenter = () => {
handleSubmit, handleSubmit,
isFilterModalOpen, isFilterModalOpen,
setIsFilterModalOpen, setIsFilterModalOpen,
setParams,
filterRegister,
handleFilterSubmit,
search,
}; };
}; };
+13 -3
View File
@@ -1,6 +1,16 @@
export * from "./useUsersQueries";
export * from "./useCompaniesQueries"; export * from "./useCompaniesQueries";
export * from "./useTenders";
export * from "./useCustomerQueries"; export * from "./useCustomerQueries";
export * from "./useFeedbackQueries";
export * from "./useProfileQueries"; export * from "./useProfileQueries";
export * from "./useFeedbackQueries" export * from "./useTenders";
export * from "./useUsersQueries";
export * from "./useCompaniesQueries";
export * from "./useCustomerQueries";
export * from "./useFeedbackQueries";
export * from "./useFlagsQueries";
export * from "./useNotificationQueries";
export * from "./useProfileQueries";
export * from "./useTenders";
export * from "./useUsersQueries";
export * from "./useAdminListQuery";
+11
View File
@@ -0,0 +1,11 @@
import { API_ENDPOINTS, userService } from "@/lib/api";
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
export const useGetAdminListQuery = (params?: Record<string, any>) => {
const queryKey = useMemo(() => [API_ENDPOINTS.USER.ADMINS, params], [params]);
return useQuery({
queryKey,
queryFn: () => userService.adminsList(params),
});
};
+12
View File
@@ -98,3 +98,15 @@ export function parseWithSchema<T>(
throw new Error(`ERROR caught in ${errorContext}`); throw new Error(`ERROR caught in ${errorContext}`);
} }
} }
export const deleteEmptyKeys = (obj: Record<string, any>) => {
const newObj = { ...obj };
for (const key in newObj) {
if (Object.prototype.hasOwnProperty.call(newObj, key)) {
if (!newObj[key]) {
delete newObj[key];
}
}
}
return newObj;
};