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>;
errors?: FieldErrors<T>;
required?: boolean;
clearable?: boolean;
onClear?: () => void;
} & (
| { placeholder?: string; defaultValue: string }
| { placeholder: string; defaultValue?: string }
@@ -42,13 +44,22 @@ export function Select<T extends FieldValues>({
onChange,
onBlur,
ref,
clearable = false,
onClear,
...props
}: PropsType<T>) {
const id = useId();
const [isOptionSelected, setIsOptionSelected] = useState(!!defaultValue);
const [value, setValue] = useState(defaultValue || "");
const error = errors && errors[name];
const handleClear = () => {
setValue("");
setIsOptionSelected(false);
onClear?.();
};
return (
<div className={cn("space-y-3", className)}>
<label
@@ -70,8 +81,9 @@ export function Select<T extends FieldValues>({
id={id}
name={name}
ref={ref}
defaultValue={defaultValue || ""}
value={value}
onChange={(e) => {
setValue(e.target.value);
setIsOptionSelected(!!e.target.value);
onChange?.(e);
}}
@@ -81,6 +93,7 @@ export function Select<T extends FieldValues>({
isOptionSelected && "text-dark dark:text-white",
error && "border-red focus:border-red dark:border-red",
prefixIcon && "pl-11.5",
clearable && value && "pr-20",
)}
>
{placeholder && (
@@ -96,6 +109,30 @@ export function Select<T extends FieldValues>({
))}
</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" />
</div>
@@ -1,57 +1,95 @@
import InputGroup from "@/components/FormElements/InputGroup";
import { Select } from "@/components/FormElements/select";
import { AdminRoles, NotificationChannels } from "@/constants/enums";
import { AdminRoles, AdminStatus } from "@/constants/enums";
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 { register } = useAdminListFilterPresenter();
const AdminListFilters = ({
filterRegister,
setIsFilterModalOpen,
isMutating,
handleFilterSubmit,
search,
}: AdminListFiltersProps) => {
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
{...register("method")}
items={getEnumAsArray(NotificationChannels)}
label="Method"
name="method"
placeholder="Method"
/>
<Select
{...register("status")}
items={getEnumAsArray(NotificationChannels)}
{...filterRegister("status")}
items={getEnumAsArray(AdminStatus)}
label="status"
name="status"
placeholder="Status"
clearable
/>
<InputGroup
{...register("email")}
{...filterRegister("search")}
name="search"
label="search"
placeholder="search"
type="search"
/>
{/* <InputGroup
{...filterRegister("email")}
name="email"
label="email"
placeholder="email"
type="email"
/>
/> */}
<Select
{...register("role")}
{...filterRegister("role")}
name="role"
items={getEnumAsArray(AdminRoles)}
label="Role"
placeholder="Role"
/>
<InputGroup
{...register("username")}
{/* <InputGroup
{...filterRegister("username")}
label="username"
name="username"
placeholder="username"
type="text"
/>
<InputGroup
{...register("full_name")}
/> */}
{/* <InputGroup
{...filterRegister("full_name")}
label="full name"
name="full_name"
placeholder="full name"
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>
);
};
+93 -74
View File
@@ -39,6 +39,10 @@ const AdminsTable = () => {
currentUser,
setStatus,
status,
handleFilterSubmit,
search,
filterRegister,
isMutating,
} = useAdminsPresenter();
return (
@@ -59,79 +63,87 @@ const AdminsTable = () => {
</TableHeader>
{isPending && <TableSkeleton column={columns.length} />}
<TableBody>
{allUsers.map((item, index) => {
return (
<TableRow
key={item.id}
className="odd:bg-gray-2 dark:odd:bg-gray-7"
>
<TableCell className="text-start" colSpan={100}>
{index + 1}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{item.full_name}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{item.email}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{item.role}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{item.username}
</TableCell>
<TableCell className="text-start" colSpan={100}>
<Status status={item.status ?? ""}>{item.status}</Status>
</TableCell>
{!allUsers.length ? (
<TableRow>
<TableCell>
<h2>No user</h2>
</TableCell>
</TableRow>
) : (
allUsers.map((item, index) => {
return (
<TableRow
key={item?.id || index}
className="odd:bg-gray-2 dark:odd:bg-gray-7"
>
<TableCell className="text-start" colSpan={100}>
{index + 1}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{item?.full_name}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{item?.email}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{item?.role}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{item?.username}
</TableCell>
<TableCell className="text-start" colSpan={100}>
<Status status={item?.status ?? ""}>{item?.status}</Status>
</TableCell>
<TableCell className="text-start xl:pr-7.5">
<div className="flex items-center justify-start gap-x-3.5">
<button
data-tooltip-id="delete"
data-tooltip-content="Delete"
data-tooltip-place="top"
className="hover:text-red-500"
onClick={() => {
setCurrentUser(item);
setIsModalOpen(true);
}}
>
<Tooltip {..._TooltipDefaultParams({ id: "delete" })} />
<span className="sr-only">Delete Admin </span>
<TrashIcon />
</button>
<button
data-tooltip-id="change-status"
data-tooltip-content="Change status"
data-tooltip-place="top"
className="hover:text-yellow-dark"
onClick={() => {
setCurrentUser(item);
setIsChangeStatusModalOpen(true);
}}
>
<Tooltip
{..._TooltipDefaultParams({ id: "change-status" })}
/>
<span className="sr-only">Change Admin Status</span>
<UserSettingIcon />
</button>
<button
data-tooltip-id="edit"
data-tooltip-content="Edit"
data-tooltip-place="top"
className="hover:text-primary"
onClick={() => router.push(`/admins/edit/${item.id}`)}
>
<Tooltip {..._TooltipDefaultParams({ id: "edit" })} />
<span className="sr-only">Edit Admin </span>
<PencilSquareIcon />
</button>
</div>
</TableCell>
</TableRow>
);
})}
<TableCell className="text-start xl:pr-7.5">
<div className="flex items-center justify-start gap-x-3.5">
<button
data-tooltip-id="delete"
data-tooltip-content="Delete"
data-tooltip-place="top"
className="hover:text-red-500"
onClick={() => {
setCurrentUser(item);
setIsModalOpen(true);
}}
>
<Tooltip {..._TooltipDefaultParams({ id: "delete" })} />
<span className="sr-only">Delete Admin </span>
<TrashIcon />
</button>
<button
data-tooltip-id="change-status"
data-tooltip-content="Change status"
data-tooltip-place="top"
className="hover:text-yellow-dark"
onClick={() => {
setCurrentUser(item);
setIsChangeStatusModalOpen(true);
}}
>
<Tooltip
{..._TooltipDefaultParams({ id: "change-status" })}
/>
<span className="sr-only">Change Admin Status</span>
<UserSettingIcon />
</button>
<button
data-tooltip-id="edit"
data-tooltip-content="Edit"
data-tooltip-place="top"
className="hover:text-primary"
onClick={() => router.push(`/admins/edit/${item.id}`)}
>
<Tooltip {..._TooltipDefaultParams({ id: "edit" })} />
<span className="sr-only">Edit Admin </span>
<PencilSquareIcon />
</button>
</div>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
<Modal
@@ -150,9 +162,16 @@ const AdminsTable = () => {
<Modal
isOpen={isFilterModalOpen}
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>
{isModalOpen && (
<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 { AdminStatus } from "@/constants/enums";
import { deleteEmptyKeys } from "@/utils/shared";
import {
useAdminListInfiniteQuery,
useChangeAdminStatus,
useDeleteAdmin,
useGetAdminListQuery,
} 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 { useState } from "react";
import { useForm } from "react-hook-form";
@@ -20,7 +22,12 @@ export const useAdminsPresenter = () => {
const [status, setStatus] = useState<string>(
currentUser?.status ?? AdminStatus.ACTIVE,
);
const isMutating = useIsMutating();
const router = useRouter();
const { register: filterRegister, handleSubmit: handleFilterSubmit } =
useForm();
const queryClient = useQueryClient();
const {
register,
formState: { errors },
@@ -33,9 +40,11 @@ export const useAdminsPresenter = () => {
});
const [params, setParams] = useState<Record<string, any>>({
...apiDefaultParams,
offset: 0,
limit: 10,
});
const pathName = usePathname();
const { data, isPending } = useAdminListInfiniteQuery(params);
const { data, isPending } = useGetAdminListQuery(params);
const { mutate: deleteAdmin } = useDeleteAdmin(() => {
setIsModalOpen(false);
});
@@ -51,7 +60,16 @@ export const useAdminsPresenter = () => {
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 = [
"row",
"full name",
@@ -63,11 +81,13 @@ export const useAdminsPresenter = () => {
];
return {
allUsers,
metadata,
columns,
isPending,
onConfirmDelete,
isModalOpen,
setIsModalOpen,
isMutating,
router,
setCurrentUser,
pathName,
@@ -82,5 +102,9 @@ export const useAdminsPresenter = () => {
handleSubmit,
isFilterModalOpen,
setIsFilterModalOpen,
setParams,
filterRegister,
handleFilterSubmit,
search,
};
};
+13 -3
View File
@@ -1,6 +1,16 @@
export * from "./useUsersQueries";
export * from "./useCompaniesQueries";
export * from "./useTenders";
export * from "./useCustomerQueries";
export * from "./useFeedbackQueries";
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}`);
}
}
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;
};