({
+ url: `${apiUrl}/${resource}/${id}`,
+ method: "GET",
+ });
+
+ return response.data;
+ },
+
+ /*saveOne: async (params: ISaveOneDataProviderParams
): Promise => {
+ const { resource, data, id } = params;
+
+ console.log(params);
+
+ const result = await httpClient.request({
+ url: `${apiUrl}/${resource}/${id}`,
+ method: "PUT",
+ data,
+ });
+
+ return result.data;
+ },*/
+
+ createOne: async (params: ICreateOneDataProviderParams
): Promise => {
+ const { resource, data } = params;
+
+ const response = await httpClient.request({
+ url: `${apiUrl}/${resource}`,
+ method: "POST",
+ data,
+ });
+
+ return response.data;
+ },
+
+ updateOne: async (params: IUpdateOneDataProviderParams
): Promise => {
+ const { resource, data, id } = params;
+
+ const response = await httpClient.request({
+ url: `${apiUrl}/${resource}/${id}`,
+ method: "PUT",
+ data,
+ });
+
+ return response.data;
+ },
+
+ removeOne: async (params: IRemoveOneDataProviderParams) => {
+ const { resource, id } = params;
+
+ await httpClient.request({
+ url: `${apiUrl}/${resource}/${id}`,
+ method: "DELETE",
+ });
+
+ return;
+ },
+
+ /*getMany: async ({ resource }) => {
+ const { body } = await httpClient.request({
+ url: `${apiUrl}/${resource}`,
+ method: "GET",
+ //...defaultRequestConfig,
+ });
+
+ return body;
+ },*/
+
+ /*create: async ({ resource, values }) => {
+ const url = `${apiUrl}/${resource}`;
+
+ const { body } = await httpClient.post(url, values, defaultRequestConfig);
+
+ return body;
+ },*/
+
+ /*createMany: async ({ resource, values }) => {
+ const response = await Promise.all(
+ values.map(async (param) => {
+ const { body } = await httpClient.post(
+ `${apiUrl}/${resource}`,
+ param
+ //defaultRequestConfig,
+ );
+ return body;
+ })
+ );
+
+ return response;
+ },*/
+
+ /*update: async ({ resource, id, values }) => {
+ const url = `${apiUrl}/${resource}/${id}`;
+ const { body } = await httpClient.patch(url, values, defaultRequestConfig);
+ return body;
+ },*/
+
+ /*updateMany: async ({ resource, ids, values }) => {
+ const response = await Promise.all(
+ ids.map(async (id) => {
+ const { body } = await httpClient.patch(
+ `${apiUrl}/${resource}/${id}`,
+ values
+ //defaultRequestConfig,
+ );
+ return body;
+ })
+ );
+
+ return response;
+ },*/
+
+ // removeMany: async ({ resource, ids }) => {
+ // const url = `${apiUrl}/${resource}/bulk-delete`;
+
+ // const { body } = await httpClient.request({
+ // url,
+ // method: "PATCH",
+ // data: {
+ // ids,
+ // },
+ // //defaultRequestConfig,
+ // });
+
+ // return body;
+ // },
+
+ // upload: async ({ resource, file, onUploadProgress }) => {
+ // const url = `${apiUrl}/${resource}`;
+ // const options = {
+ // //...defaultRequestConfig,
+ // onUploadProgress,
+ // headers: {
+ // //...defaultRequestConfig.headers,
+ // "Content-Type": "multipart/form-data",
+ // },
+ // };
+
+ // const formData = new FormData();
+ // formData.append("file", file);
+
+ // const { body } = await httpClient.post(url, formData, options);
+ // return body;
+ // },
+
+ /*uploadMany: async ({ resource, values }) => {
+ const url = `${apiUrl}/${resource}`;
+ const options = {
+ //...defaultRequestConfig,
+ headers: {
+ ...defaultRequestConfig.headers,
+ 'Content-Type': 'multipart/form-data'
+ }
+ };
+
+ const response = await Promise.all(
+ values.map(async (value) => {
+ const { body } = await httpClient.post(
+ url,
+ value,
+ options
+ );
+ return body;
+ }),
+ );
+
+ return response;
+ },*/
+
+ /*custom: async ({ url, method, filters, sort, payload, query, headers }) => {
+ let requestUrl = `${url}?`;
+
+ if (sort) {
+ const generatedSort = extractSortParams(sort);
+ if (generatedSort) {
+ const { _sort, _order } = generatedSort;
+ const sortQuery = {
+ _sort: _sort.join(","),
+ _order: _order.join(","),
+ };
+ requestUrl = `${requestUrl}&${queryString.stringify(sortQuery)}`;
+ }
+ }
+
+ if (filters) {
+ const filterQuery = extractFilterParams(filters);
+ requestUrl = `${requestUrl}&${queryString.stringify(filterQuery)}`;
+ }
+
+ if (query) {
+ requestUrl = `${requestUrl}&${queryString.stringify(query)}`;
+ }
+
+ if (headers) {
+ httpClient.defaults.headers = {
+ ...httpClient.defaults.headers,
+ ...headers,
+ };
+ }
+
+ let axiosResponse;
+ switch (method) {
+ case "put":
+ case "post":
+ case "patch":
+ axiosResponse = await httpClient[method](url, payload);
+ break;
+ case "remove":
+ axiosResponse = await httpClient.delete(url);
+ break;
+ default:
+ axiosResponse = await httpClient.get(requestUrl);
+ break;
+ }
+
+ const { data } = axiosResponse;
+
+ return Promise.resolve({ data });
+ },*/
+});
+
+const extractSortParams = (sort: ISortItemDataProviderParam[] = []) =>
+ sort.map((item) => `${item.order === "DESC" ? "-" : "+"}${item.field}`);
+
+const extractFilterParams = (filters?: IFilterItemDataProviderParam[]): string[] => {
+ let queryFilters: string[] = [];
+ if (filters) {
+ queryFilters = filters
+ .filter((item) => item.field !== "q")
+ .map(({ field, operator, value }) => `${field}[${operator}]${value}`);
+ }
+ return queryFilters;
+};
+
+const generateQuickSearch = (filters?: IFilterItemDataProviderParam[]): string | undefined => {
+ let quickSearch: string | undefined = undefined;
+ if (filters) {
+ const qsArray = filters.filter((item) => item.field === "q");
+ if (qsArray.length > 0) {
+ quickSearch = qsArray[0].value;
+ }
+ }
+ return quickSearch;
+};
+
+const extractPaginationParams = (pagination?: IPaginationDataProviderParam) => {
+ const { pageIndex = INITIAL_PAGE_INDEX, pageSize = INITIAL_PAGE_SIZE } = pagination || {};
+
+ return {
+ page: pageIndex,
+ limit: pageSize,
+ };
+};
diff --git a/client/src/lib/axios/createJSONDataProvider.ts b/client/src/lib/axios/createJSONDataProvider.ts
new file mode 100644
index 0000000..4fecbb3
--- /dev/null
+++ b/client/src/lib/axios/createJSONDataProvider.ts
@@ -0,0 +1,65 @@
+import { IListResponse_DTO } from "@shared/contexts";
+import {
+ IDataSource,
+ IGetListDataProviderParams,
+ IGetOneDataProviderParams,
+ IPaginationDataProviderParam,
+} from "../hooks/useDataSource/DataSource";
+import { INITIAL_PAGE_INDEX, INITIAL_PAGE_SIZE } from "../hooks/usePagination";
+
+export const createJSONDataProvider = (jsonData: unknown[]): IDataSource => ({
+ name: () => "JSONDataProvider",
+
+ getList: (params: IGetListDataProviderParams): Promise> => {
+ const { resource, quickSearchTerm, pagination, filters, sort } = params;
+ const queryPagination = extractPaginationParams(pagination);
+
+ const items = jsonData.slice(
+ queryPagination.page * queryPagination.limit,
+ queryPagination.page * queryPagination.limit + queryPagination.limit
+ );
+
+ const totalItems = jsonData.length;
+ const totalPages = Math.ceil(totalItems / queryPagination.limit);
+
+ const response: IListResponse_DTO = {
+ page: queryPagination.page,
+ per_page: queryPagination.limit,
+ total_pages: totalPages,
+ total_items: totalItems,
+ items,
+ };
+
+ return Promise.resolve(response);
+ },
+
+ getOne: async (params: IGetOneDataProviderParams): Promise => {
+ /*const { resource, id } = params;
+
+ const response = await httpClient.request({
+ url: `${apiUrl}/${resource}/${id}`,
+ method: "GET",
+ });
+
+ return response.data;*/
+ },
+
+ createOne: (params: ICreateOneDataProviderParams
) => {
+ throw Error;
+ },
+ updateOne:
(params: IUpdateOneDataProviderParams
) => {
+ throw Error;
+ },
+ removeOne: (params: IRemoveOneDataProviderParams) => {
+ throw Error;
+ },
+});
+
+const extractPaginationParams = (pagination?: IPaginationDataProviderParam) => {
+ const { pageIndex = INITIAL_PAGE_INDEX, pageSize = INITIAL_PAGE_SIZE } = pagination || {};
+
+ return {
+ page: pageIndex,
+ limit: pageSize,
+ };
+};
diff --git a/client/src/lib/axios/index.ts b/client/src/lib/axios/index.ts
new file mode 100644
index 0000000..76c3dd2
--- /dev/null
+++ b/client/src/lib/axios/index.ts
@@ -0,0 +1,2 @@
+export * from "./HttpError";
+export * from "./createAxiosDataProvider";
diff --git a/client/src/lib/axios/setupInterceptors.ts b/client/src/lib/axios/setupInterceptors.ts
new file mode 100644
index 0000000..2d88e4f
--- /dev/null
+++ b/client/src/lib/axios/setupInterceptors.ts
@@ -0,0 +1,104 @@
+import {
+ AxiosError,
+ AxiosInstance,
+ AxiosResponse,
+ InternalAxiosRequestConfig,
+} from "axios";
+
+//use(onFulfilled?: ((value: V) => V | Promise) | null,
+//onRejected?: ((error: any) => any) | null, options?: AxiosInterceptorOptions): number;
+
+const onRequest = (
+ request: InternalAxiosRequestConfig
+): InternalAxiosRequestConfig => {
+ /*console.group("[request]");
+ console.dir(request);
+ console.groupEnd();*/
+
+ return request;
+};
+
+const onRequestError = (error: AxiosError): Promise => {
+ /*console.group("[request error]");
+ console.dir(error);
+ console.groupEnd();*/
+
+ return Promise.reject(error);
+};
+
+const onResponse = (response: AxiosResponse): AxiosResponse => {
+ console.group("[response]");
+ console.dir(response);
+ console.groupEnd();
+
+ const config = response?.config;
+ if (config.raw) {
+ return response;
+ }
+
+ /*if (response.status === 200) {
+ const data = response?.data;
+ if (!data) {
+ throw new HttpError("API Error. No data!");
+ }
+ return data;
+ }*/
+ //throw new HttpError("API Error! Invalid status code!");
+
+ return response;
+};
+
+const onResponseError = (error: AxiosError): Promise => {
+ console.group("[response error]");
+ console.log(error);
+
+ if (error.response) {
+ // La respuesta fue hecha y el servidor respondió con un código de estado
+ // que esta fuera del rango de 2xx
+ console.log("1 => El servidor respondió con un código de estado > 200");
+ console.log(error.response.data);
+ console.log(error.response.status);
+
+ switch (error.response.status) {
+ case 400:
+ console.error("Bad Request");
+ break;
+ case 401:
+ console.error("UnAuthorized");
+ break;
+ case 403:
+ console.error("Forbidden");
+ break;
+ /*AppEvents.publish(Events.N_Error, {
+ message: "Forbidden",
+ description: "Operation ",
+ });*/
+ case 404:
+ console.error("Not found");
+ break;
+
+ case 422:
+ console.error("Unprocessable Content");
+ throw error.response.data;
+ }
+ console.error(error.response.status);
+ } else if (error.request) {
+ // La petición fue hecha pero no se recibió respuesta
+ console.log("2 => El servidor no respondió");
+ console.error(error);
+ } else {
+ // Algo paso al preparar la petición que lanzo un Error
+ console.log("3 => Error desconocido");
+ console.error(error);
+ }
+ console.groupEnd();
+ throw error;
+};
+
+export function setupInterceptorsTo(
+ axiosInstance: AxiosInstance
+): AxiosInstance {
+ axiosInstance.interceptors.request.use(onRequest, onRequestError);
+ axiosInstance.interceptors.response.use(onResponse, onResponseError);
+ return axiosInstance;
+}
diff --git a/client/src/lib/helpers/index.ts b/client/src/lib/helpers/index.ts
new file mode 100644
index 0000000..e69de29
diff --git a/client/src/lib/hooks/index.ts b/client/src/lib/hooks/index.ts
new file mode 100644
index 0000000..f3f4ba4
--- /dev/null
+++ b/client/src/lib/hooks/index.ts
@@ -0,0 +1,15 @@
+/*export * from "./useBreadcrumbs";
+export * from "./useDataSource";
+export * from "./useDataTable";
+export * from "./useForm";
+export * from "./useLoadingOvertime";
+export * from "./useMounted";
+export * from "./usePagination";
+export * from "./useRemoveAlertDialog";
+export * from "./useResizeObserver";
+
+export * from "./useUrlId";
+*/
+
+export * from "./useAuth";
+export * from "./useTheme";
diff --git a/client/src/lib/hooks/useAuth/AuthActions.ts b/client/src/lib/hooks/useAuth/AuthActions.ts
new file mode 100644
index 0000000..b4dcd80
--- /dev/null
+++ b/client/src/lib/hooks/useAuth/AuthActions.ts
@@ -0,0 +1,41 @@
+export type SuccessNotificationResponse = {
+ message: string;
+ description?: string;
+};
+
+export type PermissionResponse = unknown;
+
+export type IdentityResponse = unknown;
+
+export type AuthActionCheckResponse = {
+ authenticated: boolean;
+ redirectTo?: string;
+ logout?: boolean;
+ error?: Error;
+};
+
+export type AuthActionOnErrorResponse = {
+ redirectTo?: string;
+ logout?: boolean;
+ error?: Error;
+};
+
+export type AuthActionResponse = {
+ success: boolean;
+ redirectTo?: string;
+ error?: Error;
+ [key: string]: unknown;
+ successNotification?: SuccessNotificationResponse;
+};
+
+export interface IAuthActions {
+ login: (params: any) => Promise;
+ logout: (params: any) => Promise;
+ check: (params?: any) => Promise;
+ onError?: (error: any) => Promise;
+ register?: (params: unknown) => Promise;
+ forgotPassword?: (params: unknown) => Promise;
+ updatePassword?: (params: unknown) => Promise;
+ getPermissions?: (params?: Record) => Promise;
+ getIdentity?: (params?: unknown) => Promise;
+}
diff --git a/client/src/lib/hooks/useAuth/AuthContext.tsx b/client/src/lib/hooks/useAuth/AuthContext.tsx
new file mode 100644
index 0000000..341aced
--- /dev/null
+++ b/client/src/lib/hooks/useAuth/AuthContext.tsx
@@ -0,0 +1,51 @@
+import { PropsWithChildren, createContext } from "react";
+import { IAuthActions } from "./AuthActions";
+
+export interface IAuthContextState extends IAuthActions {}
+
+export const AuthContext = createContext(null);
+
+export const AuthProvider = ({
+ children,
+ authActions,
+}: PropsWithChildren<{ authActions: Partial }>) => {
+ const handleLogin = (params: unknown) => {
+ try {
+ return Promise.resolve(authActions.login?.(params));
+ } catch (error) {
+ console.error(error);
+ return Promise.reject(error);
+ }
+ };
+
+ const handleLogout = (params: unknown) => {
+ try {
+ return Promise.resolve(authActions.logout?.(params));
+ } catch (error) {
+ console.error(error);
+ return Promise.reject(error);
+ }
+ };
+
+ const handleCheck = async (params: unknown) => {
+ try {
+ return Promise.resolve(authActions.check?.(params));
+ } catch (error) {
+ console.error(error);
+ return Promise.reject(error);
+ }
+ };
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/client/src/lib/hooks/useAuth/index.ts b/client/src/lib/hooks/useAuth/index.ts
new file mode 100644
index 0000000..fdd630d
--- /dev/null
+++ b/client/src/lib/hooks/useAuth/index.ts
@@ -0,0 +1,5 @@
+export * from "./AuthActions";
+export * from "./AuthContext";
+export * from "./useAuth";
+export * from "./useIsLoggedIn";
+export * from "./useLogin";
diff --git a/client/src/lib/hooks/useAuth/useAuth.tsx b/client/src/lib/hooks/useAuth/useAuth.tsx
new file mode 100644
index 0000000..bd5e96e
--- /dev/null
+++ b/client/src/lib/hooks/useAuth/useAuth.tsx
@@ -0,0 +1,8 @@
+import { useContext } from "react";
+import { AuthContext } from "./AuthContext";
+
+export const useAuth = () => {
+ const context = useContext(AuthContext);
+ if (context === null) throw new Error("useAuth must be used within a AuthProvider");
+ return context;
+};
diff --git a/client/src/lib/hooks/useAuth/useIsLoggedIn.tsx b/client/src/lib/hooks/useAuth/useIsLoggedIn.tsx
new file mode 100644
index 0000000..fd8da87
--- /dev/null
+++ b/client/src/lib/hooks/useAuth/useIsLoggedIn.tsx
@@ -0,0 +1,16 @@
+import { AuthActionCheckResponse, useAuth } from "@/lib/hooks";
+import { UseMutationOptions, useMutation } from "@tanstack/react-query";
+import { useQueryKey } from "../useQueryKey";
+
+export const useIsLoggedIn = (
+ params?: UseMutationOptions
+) => {
+ const keys = useQueryKey();
+ const { check } = useAuth();
+
+ return useMutation({
+ mutationKey: keys().auth().action("check").get(),
+ mutationFn: check,
+ ...params,
+ });
+};
diff --git a/client/src/lib/hooks/useAuth/useLogin.tsx b/client/src/lib/hooks/useAuth/useLogin.tsx
new file mode 100644
index 0000000..7c924ef
--- /dev/null
+++ b/client/src/lib/hooks/useAuth/useLogin.tsx
@@ -0,0 +1,36 @@
+import { AuthActionResponse, useAuth } from "@/lib/hooks";
+import { ILogin_DTO } from "@shared/contexts";
+import { UseMutationOptions, useMutation } from "@tanstack/react-query";
+import { useNavigate } from "react-router-dom";
+import { toast } from "react-toastify";
+import { useQueryKey } from "../useQueryKey";
+
+export const useLogin = (params?: UseMutationOptions) => {
+ const { onSuccess, onError, ...restParams } = params || {};
+ const keys = useQueryKey();
+ const { login } = useAuth();
+ const navigate = useNavigate();
+
+ return useMutation({
+ mutationKey: keys().auth().action("login").get(),
+ mutationFn: login,
+ onSuccess: async (data, variables, context) => {
+ const { success, redirectTo } = data;
+ if (success && redirectTo) {
+ navigate(redirectTo);
+ }
+ if (onSuccess) {
+ onSuccess(data, variables, context);
+ }
+ },
+ onError: (error, variables, context) => {
+ const { message } = error;
+ toast.error(message);
+
+ if (onError) {
+ onError(error, variables, context);
+ }
+ },
+ ...restParams,
+ });
+};
diff --git a/client/src/lib/hooks/useBreadcrumbs.tsx b/client/src/lib/hooks/useBreadcrumbs.tsx
new file mode 100644
index 0000000..65e1a1e
--- /dev/null
+++ b/client/src/lib/hooks/useBreadcrumbs.tsx
@@ -0,0 +1,19 @@
+import { useEffect, useState } from "react";
+import { useMatches } from "react-router";
+
+export const useBreadcrumbs = (): any[] => {
+ const [crumbs, setCrumbs] = useState([]);
+ let matches = useMatches();
+
+ useEffect(() => {
+ const _crumbs = matches
+ // @ts-ignore
+ .filter((match) => Boolean(match.handle?.crumb))
+ // @ts-ignore
+ .map((match) => match.handle?.crumb(match.data));
+
+ setCrumbs(_crumbs);
+ }, matches);
+
+ return crumbs;
+};
diff --git a/client/src/lib/hooks/useDataSource/DataSource.ts b/client/src/lib/hooks/useDataSource/DataSource.ts
new file mode 100644
index 0000000..1cebc92
--- /dev/null
+++ b/client/src/lib/hooks/useDataSource/DataSource.ts
@@ -0,0 +1,82 @@
+import { IListResponse_DTO } from "@shared/contexts";
+
+export interface IPaginationDataProviderParam {
+ pageIndex: number;
+ pageSize: number;
+}
+
+export interface ISortItemDataProviderParam {
+ order: string;
+ field: string;
+}
+
+export interface IFilterItemDataProviderParam {
+ field: string;
+ operator?: string;
+ value?: string;
+}
+
+export interface IGetListDataProviderParams {
+ resource: string;
+ quickSearchTerm?: string;
+ pagination?: IPaginationDataProviderParam;
+ sort?: ISortItemDataProviderParam[];
+ filters?: IFilterItemDataProviderParam[];
+}
+
+export interface IGetOneDataProviderParams {
+ resource: string;
+ id: string;
+}
+
+export interface ISaveOneDataProviderParams {
+ resource: string;
+ data: T;
+ id: string;
+}
+
+export interface ICreateOneDataProviderParams {
+ resource: string;
+ data: T;
+}
+
+export interface IUpdateOneDataProviderParams {
+ resource: string;
+ data: T;
+ id: string;
+}
+
+export interface IRemoveOneDataProviderParams {
+ resource: string;
+ id: string;
+}
+
+/*export interface ICustomDataProviderParam {
+ resource: string;
+ method: string;
+ params: any;
+}*/
+
+export interface IDataSource {
+ name: () => string;
+ getList: (params: IGetListDataProviderParams) => Promise>;
+ getOne: (params: IGetOneDataProviderParams) => Promise;
+ //saveOne: (params: ISaveOneDataProviderParams
) => Promise;
+ createOne: (params: ICreateOneDataProviderParams
) => Promise;
+ updateOne: (params: IUpdateOneDataProviderParams
) => Promise;
+ removeOne: (params: IRemoveOneDataProviderParams) => Promise;
+
+ //custom: (params: ICustomDataProviderParam) => Promise;
+
+ //getApiUrl: () => string;
+
+ //create: () => any;
+ //createMany: () => any;
+ //removeMany: () => any;
+ //getMany: () => any;
+ //update: () => any;
+ //updateMany: () => any;
+ //upload: () => any;
+ //custom: () => any;
+ //getApiUrl: () => string;
+}
diff --git a/client/src/lib/hooks/useDataSource/DataSourceContext.tsx b/client/src/lib/hooks/useDataSource/DataSourceContext.tsx
new file mode 100644
index 0000000..19f47d5
--- /dev/null
+++ b/client/src/lib/hooks/useDataSource/DataSourceContext.tsx
@@ -0,0 +1,11 @@
+import { PropsWithChildren, createContext } from "react";
+import { IDataSource } from "./DataSource";
+
+export const DataSourceContext = createContext(null);
+
+export const DataSourceProvider = ({
+ dataSource,
+ children,
+}: PropsWithChildren<{
+ dataSource: IDataSource;
+}>) => {children} ;
diff --git a/client/src/lib/hooks/useDataSource/index.ts b/client/src/lib/hooks/useDataSource/index.ts
new file mode 100644
index 0000000..fbea387
--- /dev/null
+++ b/client/src/lib/hooks/useDataSource/index.ts
@@ -0,0 +1,10 @@
+// export * from './useApiUrl';
+// export * from './useCreateMany';
+export * from "./useList";
+export * from "./useMany";
+export * from "./useOne";
+export * from "./useRemove";
+export * from "./useRemoveMany";
+export * from "./useSave";
+// export * from './useUpdateMany';
+// export * from './useUpload';
diff --git a/client/src/lib/hooks/useDataSource/useDataSource.tsx b/client/src/lib/hooks/useDataSource/useDataSource.tsx
new file mode 100644
index 0000000..220ae02
--- /dev/null
+++ b/client/src/lib/hooks/useDataSource/useDataSource.tsx
@@ -0,0 +1,10 @@
+import { useContext } from "react";
+import { DataSourceContext } from "./DataSourceContext";
+
+export const useDataSource = () => {
+ const context = useContext(DataSourceContext);
+ if (context === undefined)
+ throw new Error("useDataSource must be used within a DataSourceProvider");
+
+ return context;
+};
diff --git a/client/src/lib/hooks/useDataSource/useList.tsx b/client/src/lib/hooks/useDataSource/useList.tsx
new file mode 100644
index 0000000..a50906d
--- /dev/null
+++ b/client/src/lib/hooks/useDataSource/useList.tsx
@@ -0,0 +1,78 @@
+import { IsResponseAListDTO } from "@shared/contexts";
+import {
+ QueryFunctionContext,
+ QueryKey,
+ UseQueryResult,
+ keepPreviousData,
+ useQuery,
+} from "@tanstack/react-query";
+
+import { useEffect, useState } from "react";
+
+import {
+ UseLoadingOvertimeOptionsProps,
+ UseLoadingOvertimeReturnType,
+ useLoadingOvertime,
+} from "../useLoadingOvertime/useLoadingOvertime";
+
+const DEFAULT_REFETCH_INTERVAL = 2 * 60 * 1000; // 2 minutes
+const DEFAULT_STALE_TIME = 60 * 1000; // 1 minute
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+export type UseListQueryOptions = {
+ queryKey: QueryKey;
+ queryFn: (context: QueryFunctionContext) => Promise;
+ enabled?: boolean;
+ select?: (data: TUseListQueryData) => TUseListQueryData;
+ queryOptions?: Record;
+} & UseLoadingOvertimeOptionsProps;
+
+export type UseListQueryResult =
+ UseQueryResult & {
+ isEmpty: boolean;
+ } & UseLoadingOvertimeReturnType;
+
+export const useList = ({
+ queryKey,
+ queryFn,
+ enabled,
+ select,
+ queryOptions = {},
+ overtimeOptions,
+}: UseListQueryOptions<
+ TUseListQueryData,
+ TUseListQueryError
+>): UseListQueryResult => {
+ const [isEmpty, setIsEmpty] = useState(false);
+
+ const queryResponse = useQuery({
+ queryKey,
+ queryFn,
+ placeholderData: keepPreviousData,
+ staleTime: DEFAULT_STALE_TIME,
+ refetchInterval: DEFAULT_REFETCH_INTERVAL,
+ refetchOnWindowFocus: true,
+ enabled: enabled && !!queryFn,
+ select,
+ ...queryOptions,
+ });
+
+ useEffect(() => {
+ if (queryResponse.isSuccess && IsResponseAListDTO(queryResponse.data)) {
+ setIsEmpty(queryResponse.data.total_items === 0);
+ }
+ }, [queryResponse]);
+
+ const { elapsedTime } = useLoadingOvertime({
+ isPending: queryResponse.isFetching,
+ interval: overtimeOptions?.interval,
+ onInterval: overtimeOptions?.onInterval,
+ });
+
+ const result = {
+ ...queryResponse,
+ overtime: { elapsedTime },
+ isEmpty,
+ };
+ return result;
+};
diff --git a/client/src/lib/hooks/useDataSource/useMany.tsx b/client/src/lib/hooks/useDataSource/useMany.tsx
new file mode 100644
index 0000000..50a9d34
--- /dev/null
+++ b/client/src/lib/hooks/useDataSource/useMany.tsx
@@ -0,0 +1,37 @@
+import {
+ QueryFunction,
+ QueryKey,
+ UseQueryResult,
+ useQuery,
+} from '@tanstack/react-query';
+
+
+export interface IUseManyQueryOptions<
+ TUseManyQueryData = unknown,
+ TUseManyQueryError = unknown
+> {
+ queryKey: QueryKey;
+ queryFn: QueryFunction;
+ enabled?: boolean;
+ select?: (data: TUseManyQueryData) => TUseManyQueryData;
+ queryOptions?: any;
+}
+
+export function useMany(
+ options: IUseManyQueryOptions
+): UseQueryResult {
+ const { queryKey, queryFn, enabled, select, queryOptions } = options;
+
+ const queryResponse = useQuery({
+ queryKey,
+ queryFn,
+ keepPreviousData: true,
+ ...queryOptions,
+ enabled,
+ select,
+
+ });
+
+ return queryResponse;
+}
+
diff --git a/client/src/lib/hooks/useDataSource/useOne.tsx b/client/src/lib/hooks/useDataSource/useOne.tsx
new file mode 100644
index 0000000..4f94c8a
--- /dev/null
+++ b/client/src/lib/hooks/useDataSource/useOne.tsx
@@ -0,0 +1,47 @@
+import {
+ QueryFunctionContext,
+ QueryKey,
+ UseQueryResult,
+ keepPreviousData,
+ useQuery,
+} from "@tanstack/react-query";
+
+import {
+ UseLoadingOvertimeOptionsProps,
+ UseLoadingOvertimeReturnType,
+} from "../useLoadingOvertime/useLoadingOvertime";
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+export type UseOneQueryOptions = {
+ queryKey: QueryKey;
+ queryFn: (context: QueryFunctionContext) => Promise;
+ enabled?: boolean;
+ autoRefresh?: boolean;
+ select?: (data: TUseOneQueryData) => TUseOneQueryData;
+ queryOptions?: Record;
+} & UseLoadingOvertimeOptionsProps;
+
+export type UseOneQueryResult =
+ UseQueryResult & {
+ isEmpty: boolean;
+ } & UseLoadingOvertimeReturnType;
+
+export function useOne({
+ queryKey,
+ queryFn,
+ enabled,
+ select,
+ queryOptions = {},
+}: UseOneQueryOptions): UseQueryResult<
+ TUseOneQueryData,
+ TUseOneQueryError
+> {
+ return useQuery({
+ queryKey,
+ queryFn,
+ placeholderData: keepPreviousData,
+ enabled,
+ select,
+ ...queryOptions,
+ });
+}
diff --git a/client/src/lib/hooks/useDataSource/useRemove.tsx b/client/src/lib/hooks/useDataSource/useRemove.tsx
new file mode 100644
index 0000000..d892dc1
--- /dev/null
+++ b/client/src/lib/hooks/useDataSource/useRemove.tsx
@@ -0,0 +1,30 @@
+import { useMutation } from '@tanstack/react-query';
+
+export interface IUseRemoveMutationOptions<
+ TUseRemoveMutationData,
+ TUseRemoveMutationError,
+ TUseRemoveMutationVariables
+> {
+ mutationFn: (
+ variables: TUseRemoveMutationVariables,
+ ) => Promise;
+}
+
+export function useRemove<
+ TUseRemoveMutationData,
+ TUseRemoveMutationError,
+ TUseRemoveMutationVariables>(options: IUseRemoveMutationOptions<
+ TUseRemoveMutationData,
+ TUseRemoveMutationError,
+ TUseRemoveMutationVariables
+ >) {
+ const { mutationFn, ...params } = options;
+
+ return useMutation<
+ TUseRemoveMutationData,
+ TUseRemoveMutationError,
+ TUseRemoveMutationVariables>({
+ mutationFn,
+ ...params
+ });
+}
diff --git a/client/src/lib/hooks/useDataSource/useRemoveMany.tsx b/client/src/lib/hooks/useDataSource/useRemoveMany.tsx
new file mode 100644
index 0000000..40ce72e
--- /dev/null
+++ b/client/src/lib/hooks/useDataSource/useRemoveMany.tsx
@@ -0,0 +1,13 @@
+import { useMutation } from '@tanstack/react-query';
+
+export interface IUseRemoveManyMutationOptions {
+ mutationFn: any;
+}
+
+export function useRemoveMany(options: IUseRemoveManyMutationOptions) {
+ const { mutationFn } = options;
+
+ return useMutation({
+ mutationFn,
+ });
+}
diff --git a/client/src/lib/hooks/useDataSource/useSave.ts b/client/src/lib/hooks/useDataSource/useSave.ts
new file mode 100644
index 0000000..1878e3a
--- /dev/null
+++ b/client/src/lib/hooks/useDataSource/useSave.ts
@@ -0,0 +1,43 @@
+import {
+ DefaultError,
+ UseMutationOptions,
+ useMutation,
+} from "@tanstack/react-query";
+
+export interface IUseSaveMutationOptions<
+ TUseSaveMutationData = unknown,
+ TUseSaveMutationError = DefaultError,
+ TUseSaveMutationVariables = unknown,
+ TUseSaveMutationContext = unknown,
+> extends UseMutationOptions<
+ TUseSaveMutationData,
+ TUseSaveMutationError,
+ TUseSaveMutationVariables,
+ TUseSaveMutationContext
+ > {}
+
+export function useSave<
+ TUseSaveMutationData = unknown,
+ TUseSaveMutationError = DefaultError,
+ TUseSaveMutationVariables = unknown,
+ TUseSaveMutationContext = unknown,
+>(
+ options: IUseSaveMutationOptions<
+ TUseSaveMutationData,
+ TUseSaveMutationError,
+ TUseSaveMutationVariables,
+ TUseSaveMutationContext
+ >,
+) {
+ const { mutationFn, ...params } = options;
+
+ return useMutation<
+ TUseSaveMutationData,
+ TUseSaveMutationError,
+ TUseSaveMutationVariables,
+ TUseSaveMutationContext
+ >({
+ mutationFn,
+ ...params,
+ });
+}
diff --git a/client/src/lib/hooks/useDataTable/helper.ts b/client/src/lib/hooks/useDataTable/helper.ts
new file mode 100644
index 0000000..fd4a9f5
--- /dev/null
+++ b/client/src/lib/hooks/useDataTable/helper.ts
@@ -0,0 +1,192 @@
+import { differenceWith, isEmpty, isEqual, reverse, unionWith } from 'lodash';
+
+export const parseQueryString = (queryString: Record) => {
+ // pagination
+ const pageIndex = parseInt(queryString.page ?? '0', 0) - 1;
+ const pageSize = Math.min(parseInt(queryString.limit ?? '10', 10), 100);
+ const pagination =
+ pageIndex >= 0 && pageSize > 0 ? { pageIndex, pageSize } : undefined;
+
+ // pagination
+ /*let pagination = undefined;
+ if (page !== undefined && limit !== undefined) {
+ let parsedPage = toSafeInteger(queryString['page']) - 1;
+ if (parsedPage < 0) parsedPage = 0;
+
+ let parsedPageSize = toSafeInteger(queryString['limit']);
+ if (parsedPageSize > 100) parsedPageSize = 100;
+ if (parsedPageSize < 5) parsedPageSize = 5;
+
+ pagination = {
+ pageIndex: parsedPage,
+ pageSize: parsedPageSize,
+ };
+ }*/
+
+ // sorter
+ // sorter
+ const sorter = (queryString.sort ?? '')
+ .split(',')
+ .map((token) => token.match(/([+-]?)([\w_]+)/i))
+ .slice(0, 3)
+ .map((item) => (item ? { id: item[2], desc: item[1] === '-' } : null))
+ .filter(Boolean);
+
+ /*let sorter = [];
+ if (sort !== undefined) {
+ sorter = sort
+ .split(',')
+ .map((token) => token.match(/([+-]?)([\w_]+)/i))
+ .slice(0, 3)
+ .map((item) =>
+ item ? { id: item[2], desc: item[1] === '-' } : null
+ );
+ }*/
+
+ // filters
+ const filters = Object.entries(queryString)
+ .filter(([key]) => key !== 'page' && key !== 'limit' && key !== 'sort')
+ .map(([key, value]) => {
+ const [, field, , , operator] =
+ key.match(/([\w]+)(([\[])([\w]+)([\]]))*/i) ?? [];
+ const sanitizedOperator = _sanitizeOperator(operator ?? '');
+ return !isEmpty(value)
+ ? { field, operator: sanitizedOperator, value }
+ : null;
+ })
+ .filter(Boolean);
+
+ /*let filters = [];
+ if (filterCandidates !== undefined) {
+ Object.keys(filterCandidates).map((token) => {
+ const [, field, , , operator] = token.match( */
+ // /([\w]+)(([\[])([\w]+)([\]]))*/i
+ /* );
+ const value = filterCandidates[token];
+
+ if (!isEmpty(value)) {
+ filters.push({
+ field,
+ operator: _sanitizeOperator(operator),
+ value,
+ });
+ }
+ });
+ }*/
+
+ return {
+ pagination,
+ sorter,
+ filters,
+ };
+};
+
+export const buildQueryString = ({ pagination, sorter, filters }) => {
+ const params = new URLSearchParams();
+
+ if (
+ pagination &&
+ pagination.pageIndex !== undefined &&
+ pagination.pageSize !== undefined
+ ) {
+ params.append('page', String(pagination.pageIndex + 1));
+ params.append('limit', String(pagination.pageSize));
+ }
+
+ if (sorter && Array.isArray(sorter) && sorter.length > 0) {
+ params.append(
+ 'sort',
+ sorter.map(({ id, desc }) => `${desc ? '-' : ''}${id}`).toString()
+ );
+ }
+
+ if (filters && Array.isArray(filters) && filters.length > 0) {
+ filters.forEach((filterItem) => {
+ if (filterItem.value !== undefined) {
+ let operator = _mapFilterOperator(filterItem.operator);
+ if (operator === 'eq') {
+ params.append(`${filterItem.field}`, filterItem.value);
+ } else {
+ params.append(
+ `${filterItem.field}[${_mapFilterOperator(
+ filterItem.operator
+ )}]`,
+ filterItem.value
+ );
+ }
+ }
+ });
+ }
+
+ return params.toString();
+};
+
+export const combineFilters = (requiredFilters = [], otherFilters = []) => [
+ ...differenceWith(otherFilters, requiredFilters, isEqual),
+ ...requiredFilters,
+];
+
+export const unionFilters = (
+ permanentFilter = [],
+ newFilters = [],
+ prevFilters = []
+) =>
+ reverse(
+ unionWith(
+ permanentFilter,
+ newFilters,
+ prevFilters,
+ (left, right) =>
+ left.field == right.field && left.operator == right.operator
+ )
+ ).filter(
+ (crudFilter) =>
+ crudFilter.value !== undefined && crudFilter.value !== null
+ );
+
+export const extractTableSortPropertiesFromColumn = (columns) => {
+ const _extractColumnSortProperies = (column) => {
+ const { canSort, isSorted, sortedIndex, isSortedDesc } = column;
+ if (!isSorted || !canSort) {
+ return undefined;
+ } else {
+ return {
+ index: sortedIndex,
+ field: column.id,
+ order: isSortedDesc ? 'DESC' : 'ASC',
+ };
+ }
+ };
+
+ return columns
+ .map((column) => _extractColumnSortProperies(column))
+ .filter((item) => item)
+ .sort((a, b) => a.index - b.index);
+};
+
+export const extractTableSortProperties = (sorter) => {
+ return sorter.map((sortItem, index) => ({
+ index,
+ field: sortItem.id,
+ order: sortItem.desc ? 'DESC' : 'ASC',
+ }));
+};
+
+export const extractTableFilterProperties = (filters) =>
+ filters.filter((item) => !isEmpty(item.value));
+
+const _sanitizeOperator = (operator) =>
+ ['eq', 'ne', 'gte', 'lte', 'like'].includes(operator) ? operator : 'eq';
+
+const _mapFilterOperator = (operator) => {
+ switch (operator) {
+ case 'ne':
+ case 'gte':
+ case 'lte':
+ return `[${operator}]`;
+ case 'contains':
+ return '[like]';
+ default:
+ return 'eq';
+ }
+};
diff --git a/client/src/lib/hooks/useDataTable/index.ts b/client/src/lib/hooks/useDataTable/index.ts
new file mode 100644
index 0000000..a625e52
--- /dev/null
+++ b/client/src/lib/hooks/useDataTable/index.ts
@@ -0,0 +1,3 @@
+export * from "./useDataTable";
+export * from "./useDataTableColumns";
+export * from "./useQueryDataTable";
diff --git a/client/src/lib/hooks/useDataTable/useDataTable.tsx b/client/src/lib/hooks/useDataTable/useDataTable.tsx
new file mode 100644
index 0000000..d8ce4a6
--- /dev/null
+++ b/client/src/lib/hooks/useDataTable/useDataTable.tsx
@@ -0,0 +1,321 @@
+import {
+ OnChangeFn,
+ PaginationState,
+ getCoreRowModel,
+ getFacetedRowModel,
+ getFacetedUniqueValues,
+ getFilteredRowModel,
+ getSortedRowModel,
+ useReactTable,
+ type ColumnDef,
+ type ColumnFiltersState,
+ type SortingState,
+ type VisibilityState,
+} from "@tanstack/react-table";
+
+import { getDataTableSelectionColumn } from "@/components";
+import React, { useCallback, useMemo, useState } from "react";
+import { useSearchParams } from "react-router-dom";
+import { DataTableFilterField } from "../../types";
+import { usePaginationParams } from "../usePagination";
+
+//import { useDebounce } from "@/hooks/use-debounce";
+
+interface UseDataTableProps {
+ /**
+ * The data for the table.
+ * @default []
+ * @type TData[]
+ */
+ data: TData[];
+
+ /**
+ * The columns of the table.
+ * @default []
+ * @type ColumnDef[]
+ */
+ columns: ColumnDef[];
+
+ /**
+ * The number of pages in the table.
+ * @type number
+ */
+ pageCount: number;
+
+ /**
+ * Enable sorting columns.
+ * @default false
+ * @type boolean
+ */
+ enableSorting?: boolean;
+
+ /**
+ * Enable hiding columns.
+ * @default false
+ * @type boolean
+ */
+ enableHiding?: boolean;
+
+ /**
+ * Enable selection rows.
+ * @default false
+ * @type boolean
+ */
+ enableRowSelection?: boolean;
+
+ /**
+ * Defines filter fields for the table. Supports both dynamic faceted filters and search filters.
+ * - Faceted filters are rendered when `options` are provided for a filter field.
+ * - Otherwise, search filters are rendered.
+ *
+ * The indie filter field `value` represents the corresponding column name in the database table.
+ * @default []
+ * @type { label: string, value: keyof TData, placeholder?: string, options?: { label: string, value: string, icon?: React.ComponentType<{ className?: string }> }[] }[]
+ * @example
+ * ```ts
+ * // Render a search filter
+ * const filterFields = [
+ * { label: "Title", value: "title", placeholder: "Search titles" }
+ * ];
+ * // Render a faceted filter
+ * const filterFields = [
+ * {
+ * label: "Status",
+ * value: "status",
+ * options: [
+ * { label: "Todo", value: "todo" },
+ * { label: "In Progress", value: "in-progress" },
+ * { label: "Done", value: "done" },
+ * { label: "Canceled", value: "canceled" }
+ * ]
+ * }
+ * ];
+ * ```
+ */
+ filterFields?: DataTableFilterField[];
+
+ /**
+ * Enable notion like column filters.
+ * Advanced filters and column filters cannot be used at the same time.
+ * @default false
+ * @type boolean
+ */
+ enableAdvancedFilter?: boolean;
+}
+
+export function useDataTable({
+ data,
+ columns,
+ pageCount,
+ enableSorting = false,
+ enableHiding = false,
+ enableRowSelection = false,
+ filterFields = [],
+ enableAdvancedFilter = false,
+}: UseDataTableProps) {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const [searchParams, setSearchParams] = useSearchParams();
+
+ const [pagination, setPagination] = usePaginationParams();
+ const [sorting, setSorting] = useState([]);
+
+ // Memoize computation of searchableColumns and filterableColumns
+ const { searchableColumns, filterableColumns } = useMemo(() => {
+ return {
+ searchableColumns: filterFields.filter((field) => !field.options),
+ filterableColumns: filterFields.filter((field) => field.options),
+ };
+ }, [filterFields]);
+
+ // Create query string
+ /*const createQueryString = useCallback(
+ (params: Record) => {
+ const newSearchParams = new URLSearchParams(searchParams?.toString());
+
+ for (const [key, value] of Object.entries(params)) {
+ if (value === null) {
+ newSearchParams.delete(key);
+ } else {
+ newSearchParams.set(key, String(value));
+ }
+ }
+
+ return newSearchParams.toString();
+ },
+ [searchParams]
+ );*/
+
+ // Initial column filters
+ const initialColumnFilters: ColumnFiltersState = useMemo(() => {
+ return Array.from(searchParams.entries()).reduce(
+ (filters, [key, value]) => {
+ const filterableColumn = filterableColumns.find(
+ (column) => column.value === key
+ );
+ const searchableColumn = searchableColumns.find(
+ (column) => column.value === key
+ );
+
+ if (filterableColumn) {
+ filters.push({
+ id: key,
+ value: value.split("."),
+ });
+ } else if (searchableColumn) {
+ filters.push({
+ id: key,
+ value: [value],
+ });
+ }
+
+ return filters;
+ },
+ []
+ );
+ }, [filterableColumns, searchableColumns, searchParams]);
+
+ // Table states
+ const [rowSelection, setRowSelection] = React.useState({});
+
+ const [columnVisibility, setColumnVisibility] =
+ React.useState({});
+
+ const [columnFilters, setColumnFilters] =
+ React.useState(initialColumnFilters);
+
+ const paginationUpdater: OnChangeFn = (updater) => {
+ if (typeof updater === "function") {
+ const newPagination = updater(pagination);
+ console.log(newPagination);
+ setPagination(newPagination);
+ }
+ };
+
+ const sortingUpdater: OnChangeFn = (updater) => {
+ if (typeof updater === "function") {
+ setSorting(updater(sorting));
+ }
+ };
+
+ // Handle server-side filtering
+ /*const debouncedSearchableColumnFilters = JSON.parse(
+ useDebounce(
+ JSON.stringify(
+ columnFilters.filter((filter) => {
+ return searchableColumns.find((column) => column.value === filter.id);
+ })
+ ),
+ 500
+ )
+ ) as ColumnFiltersState;*/
+
+ /*const filterableColumnFilters = columnFilters.filter((filter) => {
+ return filterableColumns.find((column) => column.value === filter.id);
+ });
+
+ const [mounted, setMounted] = useState(false);*/
+
+ /*useEffect(() => {
+ // Opt out when advanced filter is enabled, because it contains additional params
+ if (enableAdvancedFilter) return;
+
+ // Prevent resetting the page on initial render
+ if (!mounted) {
+ setMounted(true);
+ return;
+ }
+
+ // Initialize new params
+ const newParamsObject = {
+ page: 1,
+ };
+
+ // Handle debounced searchable column filters
+ for (const column of debouncedSearchableColumnFilters) {
+ if (typeof column.value === "string") {
+ Object.assign(newParamsObject, {
+ [column.id]: typeof column.value === "string" ? column.value : null,
+ });
+ }
+ }
+
+ // Handle filterable column filters
+ for (const column of filterableColumnFilters) {
+ if (typeof column.value === "object" && Array.isArray(column.value)) {
+ Object.assign(newParamsObject, { [column.id]: column.value.join(".") });
+ }
+ }
+
+ // Remove deleted values
+ for (const key of searchParams.keys()) {
+ if (
+ (searchableColumns.find((column) => column.value === key) &&
+ !debouncedSearchableColumnFilters.find(
+ (column) => column.id === key
+ )) ||
+ (filterableColumns.find((column) => column.value === key) &&
+ !filterableColumnFilters.find((column) => column.id === key))
+ ) {
+ Object.assign(newParamsObject, { [key]: null });
+ }
+ }
+
+ // After cumulating all the changes, push new params
+ navigate(`${location.pathname}?${createQueryString(newParamsObject)}`);
+
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ //JSON.stringify(debouncedSearchableColumnFilters),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ JSON.stringify(filterableColumnFilters),
+ ]);*/
+
+ const getTableColumns = useCallback(() => {
+ const _columns = columns;
+ if (enableRowSelection) {
+ _columns.unshift(getDataTableSelectionColumn());
+ }
+ return _columns;
+ }, [columns, enableRowSelection]);
+
+ const table = useReactTable({
+ columns: getTableColumns(),
+ data,
+ getCoreRowModel: getCoreRowModel(),
+ //getPaginationRowModel: getPaginationRowModel(),
+
+ state: {
+ pagination,
+ sorting,
+ columnVisibility,
+ rowSelection,
+ columnFilters,
+ },
+
+ enableRowSelection,
+ onRowSelectionChange: setRowSelection,
+
+ manualSorting: true,
+ enableSorting,
+ getSortedRowModel: getSortedRowModel(),
+ onSortingChange: sortingUpdater,
+
+ enableHiding,
+ onColumnVisibilityChange: setColumnVisibility,
+
+ manualPagination: true,
+ pageCount: pageCount ?? -1,
+ onPaginationChange: paginationUpdater,
+
+ getFilteredRowModel: getFilteredRowModel(),
+
+ manualFiltering: true,
+ onColumnFiltersChange: setColumnFilters,
+
+ getFacetedRowModel: getFacetedRowModel(),
+ getFacetedUniqueValues: getFacetedUniqueValues(),
+ });
+
+ return { table };
+}
diff --git a/client/src/lib/hooks/useDataTable/useDataTableColumns.tsx b/client/src/lib/hooks/useDataTable/useDataTableColumns.tsx
new file mode 100644
index 0000000..520e299
--- /dev/null
+++ b/client/src/lib/hooks/useDataTable/useDataTableColumns.tsx
@@ -0,0 +1,42 @@
+import { ColumnDef } from "@tanstack/react-table";
+import { useEffect, useState } from "react";
+
+// Función para calcular las columnas
+export function useDataTableColumns(
+ data: T[],
+ extraColumns: ColumnDef[] = []
+): ColumnDef[] {
+ const [columns, setColumns] = useState[]>([]);
+
+ useEffect(() => {
+ if (!data || data.length === 0) {
+ setColumns([]);
+ return;
+ }
+
+ // Obtener las claves de todas las propiedades de todos los elementos
+ const keys = data.reduce((acc: string[], item: T) => {
+ return acc.concat(Object.keys(item));
+ }, []);
+
+ // Eliminar claves duplicadas y ordenarlas alfabéticamente
+ const uniqueKeys = Array.from(new Set(keys)).sort();
+
+ // Crear una columna para cada clave
+ const calculatedColumns: ColumnDef[] = uniqueKeys.map((key) => {
+ return {
+ id: key,
+ header: key,
+ accessorKey: key.toLowerCase().replace(/\s+/g, "_"),
+ };
+ });
+
+ const finalColumns = extraColumns
+ ? calculatedColumns.concat(extraColumns)
+ : calculatedColumns;
+
+ setColumns(finalColumns);
+ }, [data, extraColumns]);
+
+ return columns;
+}
diff --git a/client/src/lib/hooks/useDataTable/useQueryDataTable.tsx b/client/src/lib/hooks/useDataTable/useQueryDataTable.tsx
new file mode 100644
index 0000000..525922f
--- /dev/null
+++ b/client/src/lib/hooks/useDataTable/useQueryDataTable.tsx
@@ -0,0 +1,127 @@
+import {
+ DataTableColumnProps,
+ getDataTableSelectionColumn,
+} from "@/components";
+import { IListResponse_DTO } from "@shared/contexts";
+import {
+ OnChangeFn,
+ PaginationState,
+ SortingState,
+ getCoreRowModel,
+ useReactTable,
+} from "@tanstack/react-table";
+import { useCallback, useMemo, useState } from "react";
+import { UseListQueryResult } from "../useDataSource";
+import { usePaginationParams } from "../usePagination";
+
+type TUseDataTableQueryResult = UseListQueryResult<
+ IListResponse_DTO,
+ TError
+>;
+
+type TUseDataTableQuery = (params: {
+ pagination: {
+ pageIndex: number;
+ pageSize: number;
+ };
+ enabled?: boolean;
+}) => TUseDataTableQueryResult;
+
+type UseDataTableProps = {
+ fetchQuery: TUseDataTableQuery;
+ enabled?: boolean;
+
+ columnOptions: DataTableColumnsOptionsProps;
+ enableSorting?: boolean;
+ enableHiding?: boolean;
+
+ //initialPage?: number;
+ //initialPageSize?: number;
+};
+
+type DataTableColumnsOptionsProps = {
+ enableSelectionColumn?: boolean;
+ columns: DataTableColumnsProps;
+};
+
+type DataTableColumnsProps = DataTableColumnProps<
+ TData,
+ TValue
+>[];
+
+export const useQueryDataTable = <
+ TData = unknown,
+ TValue = unknown,
+ TError = Error
+>({
+ fetchQuery,
+ enabled = true,
+
+ columnOptions,
+ enableSorting = false,
+ enableHiding = false,
+}: UseDataTableProps) => {
+ const defaultData = useMemo(() => [], []);
+
+ const [rowSelection, setRowSelection] = useState({});
+ const [pagination, setPagination] = usePaginationParams();
+ const [sorting, setSorting] = useState([]);
+
+ const paginationUpdater: OnChangeFn = (updater) => {
+ if (typeof updater === "function") {
+ setPagination(updater(pagination));
+ }
+ };
+
+ const sortingUpdater: OnChangeFn = (updater) => {
+ if (typeof updater === "function") {
+ setSorting(updater(sorting));
+ }
+ };
+
+ const getTableColumns = useCallback(() => {
+ const _columns = columnOptions.columns;
+ if (columnOptions.enableSelectionColumn) {
+ _columns.unshift(getDataTableSelectionColumn());
+ }
+ return _columns;
+ }, [columnOptions]);
+
+ const queryResult: TUseDataTableQueryResult = fetchQuery({
+ pagination,
+ enabled,
+ });
+
+ const table = useReactTable({
+ columns: getTableColumns(),
+ data: queryResult.data?.items ?? defaultData,
+ getCoreRowModel: getCoreRowModel(),
+ //getPaginationRowModel: getPaginationRowModel(),
+
+ enableRowSelection: columnOptions.enableSelectionColumn,
+ onRowSelectionChange: setRowSelection,
+
+ enableSorting,
+ onSortingChange: sortingUpdater,
+
+ enableHiding,
+ //onColumnVisibilityChange: columnVisibilityUpdater,
+
+ state: {
+ pagination,
+ sorting,
+ rowSelection,
+ },
+ manualPagination: true,
+ //autoResetPageIndex: true,
+ pageCount: queryResult?.data?.total_pages ?? -1,
+ onPaginationChange: paginationUpdater,
+
+ debugTable: true,
+ });
+
+ return {
+ table,
+ queryResult,
+ };
+};
diff --git a/client/src/lib/hooks/useLoadingOvertime/index.ts b/client/src/lib/hooks/useLoadingOvertime/index.ts
new file mode 100644
index 0000000..8d0c92d
--- /dev/null
+++ b/client/src/lib/hooks/useLoadingOvertime/index.ts
@@ -0,0 +1 @@
+export * from "./useLoadingOvertime";
diff --git a/client/src/lib/hooks/useLoadingOvertime/useLoadingOvertime.ts b/client/src/lib/hooks/useLoadingOvertime/useLoadingOvertime.ts
new file mode 100644
index 0000000..994afde
--- /dev/null
+++ b/client/src/lib/hooks/useLoadingOvertime/useLoadingOvertime.ts
@@ -0,0 +1,104 @@
+import { useEffect, useState } from "react";
+
+export type UseLoadingOvertimeRefineContext = Omit<
+ UseLoadingOvertimeCoreProps,
+ "isPending" | "interval"
+> &
+ Required>;
+
+export type UseLoadingOvertimeOptionsProps = {
+ overtimeOptions?: UseLoadingOvertimeCoreOptions;
+};
+
+export type UseLoadingOvertimeReturnType = {
+ overtime: {
+ elapsedTime?: number;
+ };
+};
+
+type UseLoadingOvertimeCoreOptions = Omit<
+ UseLoadingOvertimeCoreProps,
+ "isPending"
+>;
+
+type UseLoadingOvertimeCoreReturnType = {
+ elapsedTime?: number;
+};
+
+export type UseLoadingOvertimeCoreProps = {
+ /**
+ * The pengind state. If true, the elapsed time will be calculated.
+ */
+ isPending: boolean;
+
+ /**
+ * The interval in milliseconds. If the pending time exceeds this time, the `onInterval` callback will be called.
+ * If not specified, the `interval` value from the `overtime` option of the `RefineProvider` will be used.
+ *
+ * @default: 1000 (1 second)
+ */
+ interval?: number;
+
+ /**
+ * The callback function that will be called when the pending time exceeds the specified time.
+ * If not specified, the `onInterval` value from the `overtime` option of the `RefineProvider` will be used.
+ *
+ * @param elapsedInterval The elapsed time in milliseconds.
+ */
+ onInterval?: (elapsedInterval: number) => void;
+};
+
+/**
+ * if you need to do something when the loading time exceeds the specified time, refine provides the `useLoadingOvertime` hook.
+ * It returns the elapsed time in milliseconds.
+ *
+ * @example
+ * const { elapsedTime } = useLoadingOvertime({
+ * isLoading,
+ * interval: 1000,
+ * onInterval(elapsedInterval) {
+ * console.log("loading overtime", elapsedInterval);
+ * },
+ * });
+ */
+export const useLoadingOvertime = ({
+ isPending,
+ interval = 1000,
+ onInterval,
+}: UseLoadingOvertimeCoreProps): UseLoadingOvertimeCoreReturnType => {
+ const [elapsedTime, setElapsedTime] = useState(undefined);
+
+ useEffect(() => {
+ let intervalFn: ReturnType;
+
+ if (isPending) {
+ intervalFn = setInterval(() => {
+ // increase elapsed time
+ setElapsedTime((prevElapsedTime) => {
+ if (prevElapsedTime === undefined) {
+ return interval;
+ }
+
+ return prevElapsedTime + interval;
+ });
+ }, interval);
+ }
+
+ return () => {
+ clearInterval(intervalFn);
+ // reset elapsed time
+ setElapsedTime(undefined);
+ };
+ }, [isPending, interval]);
+
+ useEffect(() => {
+ // call onInterval callback
+ if (onInterval && elapsedTime) {
+ onInterval(elapsedTime);
+ }
+ }, [elapsedTime]);
+
+ return {
+ elapsedTime,
+ };
+};
diff --git a/client/src/lib/hooks/useMounted/index.ts b/client/src/lib/hooks/useMounted/index.ts
new file mode 100644
index 0000000..6c1ec66
--- /dev/null
+++ b/client/src/lib/hooks/useMounted/index.ts
@@ -0,0 +1 @@
+export * from "./useMounted";
diff --git a/client/src/lib/hooks/useMounted/useMounted.ts b/client/src/lib/hooks/useMounted/useMounted.ts
new file mode 100644
index 0000000..9955550
--- /dev/null
+++ b/client/src/lib/hooks/useMounted/useMounted.ts
@@ -0,0 +1,13 @@
+import * as React from "react";
+
+export const useMounted = () => {
+ const [mounted, setMounted] = React.useState(false);
+
+ React.useEffect(() => {
+ setMounted(true);
+
+ return () => setMounted(false);
+ }, []);
+
+ return mounted;
+};
diff --git a/client/src/lib/hooks/usePagination/index.ts b/client/src/lib/hooks/usePagination/index.ts
new file mode 100644
index 0000000..1d72ed7
--- /dev/null
+++ b/client/src/lib/hooks/usePagination/index.ts
@@ -0,0 +1,2 @@
+export * from "./usePagination";
+export * from "./usePaginationParams";
diff --git a/client/src/lib/hooks/usePagination/usePagination.tsx b/client/src/lib/hooks/usePagination/usePagination.tsx
new file mode 100644
index 0000000..4786539
--- /dev/null
+++ b/client/src/lib/hooks/usePagination/usePagination.tsx
@@ -0,0 +1,50 @@
+import { useState } from "react";
+
+export const INITIAL_PAGE_INDEX = 0;
+export const INITIAL_PAGE_SIZE = 10;
+
+export const MIN_PAGE_INDEX = 0;
+export const MIN_PAGE_SIZE = 1;
+
+export const MAX_PAGE_SIZE = Number.MAX_SAFE_INTEGER;
+
+export const DEFAULT_PAGE_SIZES = [10, 25, 50, 100];
+
+export interface PaginationState {
+ pageIndex: number;
+ pageSize: number;
+}
+
+export const defaultPaginationState = {
+ pageIndex: INITIAL_PAGE_INDEX,
+ pageSize: INITIAL_PAGE_SIZE,
+};
+
+export const usePagination = (
+ initialPageIndex: number = INITIAL_PAGE_INDEX,
+ initialPageSize: number = INITIAL_PAGE_SIZE
+) => {
+ const [pagination, setPagination] = useState({
+ pageIndex: initialPageIndex,
+ pageSize: initialPageSize,
+ });
+
+ const updatePagination = (newPagination: PaginationState) => {
+ // Realiza comprobaciones antes de actualizar el estado
+
+ if (newPagination.pageIndex < INITIAL_PAGE_INDEX) {
+ newPagination.pageIndex = INITIAL_PAGE_INDEX;
+ }
+
+ if (
+ newPagination.pageSize < MIN_PAGE_SIZE ||
+ newPagination.pageSize > MAX_PAGE_SIZE
+ ) {
+ return;
+ }
+
+ setPagination(newPagination);
+ };
+
+ return [pagination, updatePagination] as const;
+};
diff --git a/client/src/lib/hooks/usePagination/usePaginationParams.tsx b/client/src/lib/hooks/usePagination/usePaginationParams.tsx
new file mode 100644
index 0000000..6122be0
--- /dev/null
+++ b/client/src/lib/hooks/usePagination/usePaginationParams.tsx
@@ -0,0 +1,52 @@
+import { useEffect, useMemo } from "react";
+import { useSearchParams } from "react-router-dom";
+import {
+ INITIAL_PAGE_INDEX,
+ INITIAL_PAGE_SIZE,
+ usePagination,
+} from "./usePagination";
+
+export const usePaginationParams = (
+ initialPageIndex: number = INITIAL_PAGE_INDEX,
+ initialPageSize: number = INITIAL_PAGE_SIZE
+) => {
+ const [urlSearchParams, setUrlSearchParams] = useSearchParams();
+
+ const urlParamPageIndex: string | null = urlSearchParams.get("page_index");
+ const urlParamPageSize: string | null = urlSearchParams.get("page_size");
+
+ const calculatedPageIndex = useMemo(() => {
+ const parsedPageIndex = parseInt(urlParamPageIndex ?? "", 10);
+ return !isNaN(parsedPageIndex) ? parsedPageIndex : initialPageIndex;
+ }, [urlParamPageIndex, initialPageIndex]);
+
+ const calculatedPageSize = useMemo(() => {
+ const parsedPageSize = parseInt(urlParamPageSize ?? "", 10);
+ return !isNaN(parsedPageSize) ? parsedPageSize : initialPageSize;
+ }, [urlParamPageSize, initialPageSize]);
+
+ const [pagination, setPagination] = usePagination(
+ calculatedPageIndex,
+ calculatedPageSize
+ );
+
+ useEffect(() => {
+ // Actualizar la URL cuando cambia la paginación
+ const actualSearchParam = Object.fromEntries(
+ new URLSearchParams(urlSearchParams)
+ );
+
+ if (
+ String(pagination.pageIndex) !== actualSearchParam.page_index ||
+ String(pagination.pageSize) !== actualSearchParam.page_size
+ ) {
+ setUrlSearchParams({
+ ...actualSearchParam,
+ page_index: String(pagination.pageIndex),
+ page_size: String(pagination.pageSize),
+ });
+ }
+ }, [pagination]);
+
+ return [pagination, setPagination] as const;
+};
diff --git a/client/src/lib/hooks/useQueryKey/KeyBuilder.ts b/client/src/lib/hooks/useQueryKey/KeyBuilder.ts
new file mode 100644
index 0000000..ce784a6
--- /dev/null
+++ b/client/src/lib/hooks/useQueryKey/KeyBuilder.ts
@@ -0,0 +1,181 @@
+type BaseKey = string | number;
+
+type ParametrizedDataActions = "list" | "infinite";
+type IdRequiredDataActions = "one";
+type IdsRequiredDataActions = "many";
+type DataMutationActions =
+ | "custom"
+ | "customMutation"
+ | "create"
+ | "createMany"
+ | "update"
+ | "updateMany"
+ | "delete"
+ | "deleteMany";
+
+type AuthActionType =
+ | "login"
+ | "logout"
+ | "identity"
+ | "register"
+ | "forgotPassword"
+ | "check"
+ | "onError"
+ | "permissions"
+ | "updatePassword";
+
+type AuditActionType = "list" | "log" | "rename";
+
+type IdType = BaseKey;
+type IdsType = IdType[];
+
+type ParamsType = any;
+
+type KeySegment = string | IdType | IdsType | ParamsType;
+
+export function arrayFindIndex(array: T[], slice: T[]): number {
+ return array.findIndex(
+ (item, index) =>
+ index <= array.length - slice.length &&
+ slice.every((sliceItem, sliceIndex) => array[index + sliceIndex] === sliceItem)
+ );
+}
+
+export function arrayReplace(array: T[], partToBeReplaced: T[], newPart: T[]): T[] {
+ const newArray: T[] = [...array];
+ const startIndex = arrayFindIndex(array, partToBeReplaced);
+
+ if (startIndex !== -1) {
+ newArray.splice(startIndex, partToBeReplaced.length, ...newPart);
+ }
+
+ return newArray;
+}
+
+export function stripUndefined(segments: KeySegment[]) {
+ return segments.filter((segment) => segment !== undefined);
+}
+
+class BaseKeyBuilder {
+ segments: KeySegment[] = [];
+
+ constructor(segments: KeySegment[] = []) {
+ this.segments = segments;
+ }
+
+ key() {
+ return this.segments;
+ }
+
+ get() {
+ return this.segments;
+ }
+}
+
+class ParamsKeyBuilder extends BaseKeyBuilder {
+ params(paramsValue?: ParamsType) {
+ return new BaseKeyBuilder([...this.segments, paramsValue]);
+ }
+}
+
+class DataIdRequiringKeyBuilder extends BaseKeyBuilder {
+ id(idValue?: IdType) {
+ return new ParamsKeyBuilder([...this.segments, idValue ? String(idValue) : undefined]);
+ }
+}
+
+class DataIdsRequiringKeyBuilder extends BaseKeyBuilder {
+ ids(...idsValue: IdsType) {
+ return new ParamsKeyBuilder([
+ ...this.segments,
+ ...(idsValue.length ? [idsValue.map((el) => String(el))] : []),
+ ]);
+ }
+}
+
+class DataResourceKeyBuilder extends BaseKeyBuilder {
+ action(actionType: ParametrizedDataActions): ParamsKeyBuilder;
+ action(actionType: IdRequiredDataActions): DataIdRequiringKeyBuilder;
+ action(actionType: IdsRequiredDataActions): DataIdsRequiringKeyBuilder;
+ action(
+ actionType: ParametrizedDataActions | IdRequiredDataActions | IdsRequiredDataActions
+ ): ParamsKeyBuilder | DataIdRequiringKeyBuilder | DataIdsRequiringKeyBuilder {
+ if (actionType === "one") {
+ return new DataIdRequiringKeyBuilder([...this.segments, actionType]);
+ }
+ if (actionType === "many") {
+ return new DataIdsRequiringKeyBuilder([...this.segments, actionType]);
+ }
+ if (["list", "infinite"].includes(actionType)) {
+ return new ParamsKeyBuilder([...this.segments, actionType]);
+ }
+ throw new Error("Invalid action type");
+ }
+}
+
+class DataKeyBuilder extends BaseKeyBuilder {
+ resource(resourceName?: string) {
+ return new DataResourceKeyBuilder([...this.segments, resourceName]);
+ }
+
+ mutation(mutationName: DataMutationActions) {
+ return new ParamsKeyBuilder([
+ ...(mutationName === "custom" ? this.segments : [this.segments[0]]),
+ mutationName,
+ ]);
+ }
+}
+
+class AuthKeyBuilder extends BaseKeyBuilder {
+ action(actionType: AuthActionType) {
+ return new ParamsKeyBuilder([...this.segments, actionType]);
+ }
+}
+
+class AccessResourceKeyBuilder extends BaseKeyBuilder {
+ action(resourceName: string) {
+ return new ParamsKeyBuilder([...this.segments, resourceName]);
+ }
+}
+
+class AccessKeyBuilder extends BaseKeyBuilder {
+ resource(resourceName?: string) {
+ return new AccessResourceKeyBuilder([...this.segments, resourceName]);
+ }
+}
+
+class AuditActionKeyBuilder extends BaseKeyBuilder {
+ action(actionType: Extract) {
+ return new ParamsKeyBuilder([...this.segments, actionType]);
+ }
+}
+
+class AuditKeyBuilder extends BaseKeyBuilder {
+ resource(resourceName?: string) {
+ return new AuditActionKeyBuilder([...this.segments, resourceName]);
+ }
+
+ action(actionType: Extract) {
+ return new ParamsKeyBuilder([...this.segments, actionType]);
+ }
+}
+
+export class KeyBuilder extends BaseKeyBuilder {
+ data(name?: string) {
+ return new DataKeyBuilder(["data", name || "default"]);
+ }
+
+ auth() {
+ return new AuthKeyBuilder(["auth"]);
+ }
+
+ access() {
+ return new AccessKeyBuilder(["access"]);
+ }
+
+ audit() {
+ return new AuditKeyBuilder(["audit"]);
+ }
+}
+
+export const keys = () => new KeyBuilder([]);
diff --git a/client/src/lib/hooks/useQueryKey/index.ts b/client/src/lib/hooks/useQueryKey/index.ts
new file mode 100644
index 0000000..afbbcc6
--- /dev/null
+++ b/client/src/lib/hooks/useQueryKey/index.ts
@@ -0,0 +1,5 @@
+import { keys } from "./KeyBuilder";
+
+export const useQueryKey = () => {
+ return keys;
+};
diff --git a/client/src/lib/hooks/useRemoveAlertDialog/index.ts b/client/src/lib/hooks/useRemoveAlertDialog/index.ts
new file mode 100644
index 0000000..d11e57a
--- /dev/null
+++ b/client/src/lib/hooks/useRemoveAlertDialog/index.ts
@@ -0,0 +1 @@
+export * from "./useRemoveAlertDialog";
diff --git a/client/src/lib/hooks/useRemoveAlertDialog/useRemoveAlertDialog.tsx b/client/src/lib/hooks/useRemoveAlertDialog/useRemoveAlertDialog.tsx
new file mode 100644
index 0000000..5e2f235
--- /dev/null
+++ b/client/src/lib/hooks/useRemoveAlertDialog/useRemoveAlertDialog.tsx
@@ -0,0 +1,107 @@
+import { cn } from "@/lib/utils";
+import { buttonVariants } from "@/ui";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/ui/alert-dialog";
+
+import { useCallback, useState } from "react";
+
+interface UseRemoveAlertDialogProps {
+ isOpen?: boolean;
+ onOpen?: () => void;
+ onClose?: () => void;
+
+ onCancel?: (event: React.SyntheticEvent) => void;
+ onRemove?: (payload: any, event: React.SyntheticEvent) => void;
+}
+
+interface UseRemoveAlertDialogReturnType {
+ RemoveAlertDialog: () => JSX.Element;
+ open: (title: string, description: string, payload?: any) => void;
+ close: () => void;
+}
+
+export const useRemoveAlertDialog = ({
+ isOpen = false,
+ onRemove,
+ onCancel,
+}: UseRemoveAlertDialogProps): UseRemoveAlertDialogReturnType => {
+ const [visible, setVisible] = useState(isOpen);
+
+ const [title, setTitle] = useState("");
+ const [description, setDescription] = useState("");
+ const [payload, setPayload] = useState({});
+
+ /*const cancelButtonProps = {
+ cancelButtonText: "Cancelar",
+ cancelButtonProps: {},
+ onCancel,
+ };
+
+ const submitButtonProps = {
+ submitButtonText: "Eliminar",
+ submitButtonProps: {},
+ onSubmit: (event: React.SyntheticEvent) => {
+ if (onRemove) {
+ onRemove(payload, event);
+ }
+ },
+ };*/
+
+ const open = useCallback(
+ (title: string, description: string, payload: any = {}) => {
+ setTitle(title);
+ setDescription(description);
+ setPayload(payload);
+ setVisible(true);
+ },
+ []
+ );
+
+ const close = () => setVisible(false);
+
+ const RemoveAlertDialog = (): JSX.Element => (
+
+
+
+ {title}
+ {description}
+
+
+ Cancelar
+ (onRemove ? onRemove(payload, event) : null)}
+ >
+ Eliminar
+
+
+
+
+ );
+
+ return { RemoveAlertDialog, open, close };
+};
+
+/*
+
+ {children}
+
+*/
diff --git a/client/src/lib/hooks/useResizeObserver.tsx b/client/src/lib/hooks/useResizeObserver.tsx
new file mode 100644
index 0000000..cba949c
--- /dev/null
+++ b/client/src/lib/hooks/useResizeObserver.tsx
@@ -0,0 +1,32 @@
+// https://github.com/wojtekmaj/react-hooks/blob/main/src/useResizeObserver.ts
+import { useEffect } from 'react';
+
+/**
+ * Observes a given element using ResizeObserver.
+ *
+ * @param {Element} [element] Element to attach ResizeObserver to
+ * @param {ResizeObserverOptions} [options] ResizeObserver options. WARNING! If you define the
+ * object in component body, make sure to memoize it.
+ * @param {ResizeObserverCallback} observerCallback ResizeObserver callback. WARNING! If you define
+ * the function in component body, make sure to memoize it.
+ * @returns {void}
+ */
+export default function useResizeObserver(
+ element: Element | null,
+ options: ResizeObserverOptions | undefined,
+ observerCallback: ResizeObserverCallback,
+ ): void {
+ useEffect(() => {
+ if (!element || !('ResizeObserver' in window)) {
+ return undefined;
+ }
+
+ const observer = new ResizeObserver(observerCallback);
+
+ observer.observe(element, options);
+
+ return () => {
+ observer.disconnect();
+ };
+ }, [element, options, observerCallback]);
+ }
\ No newline at end of file
diff --git a/client/src/lib/hooks/useTheme/index.ts b/client/src/lib/hooks/useTheme/index.ts
new file mode 100644
index 0000000..0fe5873
--- /dev/null
+++ b/client/src/lib/hooks/useTheme/index.ts
@@ -0,0 +1 @@
+export * from "./useTheme";
diff --git a/client/src/lib/hooks/useTheme/useTheme.tsx b/client/src/lib/hooks/useTheme/useTheme.tsx
new file mode 100644
index 0000000..f846bf1
--- /dev/null
+++ b/client/src/lib/hooks/useTheme/useTheme.tsx
@@ -0,0 +1,72 @@
+import { createContext, useContext, useEffect, useState } from "react";
+
+type Theme = "dark" | "light" | "system";
+
+type ThemeProviderProps = {
+ children: React.ReactNode;
+ defaultTheme?: Theme;
+ storageKey?: string;
+};
+
+type ThemeProviderState = {
+ theme: Theme;
+ setTheme: (theme: Theme) => void;
+};
+
+const initialState: ThemeProviderState = {
+ theme: "system",
+ setTheme: () => null,
+};
+
+const ThemeProviderContext = createContext(initialState);
+
+export function ThemeProvider({
+ children,
+ defaultTheme = "system",
+ storageKey = "vite-ui-theme",
+ ...props
+}: ThemeProviderProps) {
+ const [theme, setTheme] = useState(
+ () => (localStorage.getItem(storageKey) as Theme) || defaultTheme
+ );
+
+ useEffect(() => {
+ const root = window.document.documentElement;
+ root.classList.remove("light", "dark");
+
+ if (theme === "system") {
+ const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
+ .matches
+ ? "dark"
+ : "light";
+
+ root.classList.add(systemTheme);
+ return;
+ }
+
+ root.classList.add(theme);
+ }, [theme]);
+
+ const value = {
+ theme,
+ setTheme: (theme: Theme) => {
+ localStorage.setItem(storageKey, theme);
+ setTheme(theme);
+ },
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export const useTheme = () => {
+ const context = useContext(ThemeProviderContext);
+
+ if (context === undefined)
+ throw new Error("useTheme must be used within a ThemeProvider");
+
+ return context;
+};
diff --git a/client/src/lib/hooks/useUrlId/index.ts b/client/src/lib/hooks/useUrlId/index.ts
new file mode 100644
index 0000000..b7b7a28
--- /dev/null
+++ b/client/src/lib/hooks/useUrlId/index.ts
@@ -0,0 +1 @@
+export * from './useUrlId';
diff --git a/client/src/lib/hooks/useUrlId/useUrlId.tsx b/client/src/lib/hooks/useUrlId/useUrlId.tsx
new file mode 100644
index 0000000..7157ed7
--- /dev/null
+++ b/client/src/lib/hooks/useUrlId/useUrlId.tsx
@@ -0,0 +1,6 @@
+import { useParams } from 'react-router-dom';
+
+export const useUrlId = (): string | undefined => {
+ const { id } = useParams<{ id?: string }>();
+ return id;
+};
diff --git a/client/src/lib/hooks/useWindowResize.tsx b/client/src/lib/hooks/useWindowResize.tsx
new file mode 100644
index 0000000..19aaebe
--- /dev/null
+++ b/client/src/lib/hooks/useWindowResize.tsx
@@ -0,0 +1,17 @@
+import { useEffect } from 'react';
+
+export const useWindowResize = (onWindowResize: (resized: boolean) => any) => {
+ useEffect(() => {
+ const onResize = () => {
+ onWindowResize && onWindowResize(true);
+ };
+
+ window.addEventListener('resize', onResize);
+
+ return () => {
+ window.removeEventListener('resize', onResize);
+ };
+ }, []);
+
+ return null;
+};
diff --git a/client/src/lib/utils.ts b/client/src/lib/utils.ts
new file mode 100644
index 0000000..a5ef193
--- /dev/null
+++ b/client/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/client/src/main.tsx b/client/src/main.tsx
new file mode 100644
index 0000000..bdf2012
--- /dev/null
+++ b/client/src/main.tsx
@@ -0,0 +1,10 @@
+import React from "react";
+import ReactDOM from "react-dom/client";
+import App from "./App.tsx";
+import "./index.css";
+
+ReactDOM.createRoot(document.getElementById("uecko")!).render(
+
+
+
+);
diff --git a/client/src/pages/ErrorPage.tsx b/client/src/pages/ErrorPage.tsx
new file mode 100644
index 0000000..e3d910e
--- /dev/null
+++ b/client/src/pages/ErrorPage.tsx
@@ -0,0 +1,51 @@
+// - - - - - ErrorPage.tsx - - - - -
+import { Button } from "@/ui";
+import { useLocation, useNavigate } from "react-router-dom";
+// import Button
+
+const isDev = process.env.REACT_APP_NODE_ENV === "development";
+const hostname = `${
+ isDev ? process.env.REACT_APP_DEV_API_URL : process.env.REACT_APP_PROD_API_URL
+}`;
+
+type ErrorPageProps = {
+ error?: string;
+};
+export const ErrorPage = (props: ErrorPageProps) => {
+ const navigate = useNavigate();
+ const location = useLocation();
+
+ const navMsg = location.state?.error;
+ const msg = navMsg ? (
+ {navMsg}
+ ) : props.error ? (
+ {props.error}
+ ) : (
+
+ The targeted page "{location.pathname}" was not found, please confirm the spelling and
+ try again.
+
+ );
+ return (
+
+ {msg}
+
+ navigate(-1)}>
+ Return to Previous Page
+
+ navigate("/home")}>
+ Return to Home Page
+
+ {
+ const endpoint = `${hostname}/logout`;
+ window.open(endpoint, "_blank");
+ }}
+ >
+ Reset Authentication
+
+
+
+ );
+};
diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx
new file mode 100644
index 0000000..97b5a28
--- /dev/null
+++ b/client/src/pages/LoginPage.tsx
@@ -0,0 +1,155 @@
+import { Container, FormTextField } from "@/components";
+import { UeckoLogo } from "@/components/UeckoLogo/UeckoLogo";
+import { useLogin } from "@/lib/hooks";
+import {
+ Alert,
+ AlertDescription,
+ AlertTitle,
+ Button,
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+ Form,
+} from "@/ui";
+import { joiResolver } from "@hookform/resolvers/joi";
+import Joi from "joi";
+import { AlertCircleIcon } from "lucide-react";
+import { useState } from "react";
+import { SubmitHandler, useForm } from "react-hook-form";
+import { Link } from "react-router-dom";
+import SpanishJoiMessages from "../spanish-joi-messages.json";
+
+type LoginDataForm = {
+ email: string;
+ password: string;
+};
+
+export const LoginPage = () => {
+ const [loading, setLoading] = useState(false);
+ const { mutate: login } = useLogin({
+ onSuccess: (data) => {
+ const { success, error } = data;
+ if (!success && error) {
+ form.setError("root", error);
+ }
+ },
+ });
+
+ const form = useForm({
+ mode: "onBlur",
+ defaultValues: {
+ email: "",
+ password: "",
+ },
+ resolver: joiResolver(
+ Joi.object({
+ email: Joi.string()
+ .email({ tlds: { allow: false } })
+ .required(),
+ password: Joi.string().min(4).alphanum().required(),
+ }),
+ {
+ messages: SpanishJoiMessages,
+ }
+ ),
+ });
+
+ const onSubmit: SubmitHandler = async (data) => {
+ console.log(data);
+ try {
+ setLoading(true);
+ login({ email: data.email, password: data.password });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ Presupuestador para distribuidores
+ Enter your email below to login to your account
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/client/src/spanish-joi-messages.json b/client/src/spanish-joi-messages.json
new file mode 100644
index 0000000..673659d
--- /dev/null
+++ b/client/src/spanish-joi-messages.json
@@ -0,0 +1,93 @@
+{
+ "any.unknown": "{{#label}}: no está permitido",
+ "any.invalid": "{{#label}}: contiene un valor invalido",
+ "any.empty": "{{#label}}: no está permitido que sea vacío",
+ "any.required": "{{#label}}: es un campo requerido",
+ "any.allowOnly": "{{#label}}: debería ser uno de las siguientes variantes: {{valids}}",
+ "any.default": "emitió un error cuando se ejecutó el metodo default",
+ "alternatives.base": "{{#label}}: no coincide con ninguna de las alternativas permitidas",
+ "array.base": "{{#label}}: debe ser un array",
+ "array.includes": "{{#label}}: en la posición {{pos}} no coincide con ninguno de los tipos permitidos",
+ "array.includesSingle": "{{#label}}: el valor de \"{{!key}}\" no coincide con ninguno de los tipos permitidos",
+ "array.includesOne": "{{#label}}: en la posición {{pos}} falló porque {{reason}}",
+ "array.includesOneSingle": "{{#label}}: el valor \"{{!key}}\" falló porque {{reason}}",
+ "array.includesRequiredUnknowns": "{{#label}}: no contiene valor/es requerido/s: {{unknownMisses}} ",
+ "array.includesRequiredKnowns": "{{#label}}: no contiene: {{knownMisses}}",
+ "array.includesRequiredBoth": "{{#label}}: no contiene {{knownMisses}} y {{unknownMisses}} otros valores requeridos",
+ "array.excludes": "{{#label}}: en la posición {{pos}} contiene un valor excluído",
+ "array.excludesSingle": "{{#label}}: el valor \"{{!key}}\" contiene un valor excluído",
+ "array.min": "{{#label}}: debe contener al menos {{limit}} items",
+ "array.max": "{{#label}}: debe contener máximo {{limit}} items",
+ "array.length": "{{#label}}: debe contener exactamente {{limit}} items",
+ "array.ordered": "{{#label}}: en la posición {{pos}} falló porque {{reason}}",
+ "array.orderedLength": "{{#label}}: en la posición {{pos}} falló porque el array debre contener como máximo {{limit}} items",
+ "array.sparse": "{{#label}}: no debe ser un array esparcido",
+ "array.unique": "{{#label}}: posición {{pos}} contiene un valor duplicado",
+ "boolean.base": "{{#label}}: debe ser un valor verdadero/falso o si/no",
+ "binary.base": "{{#label}}: debe ser un buffer o un string",
+ "binary.min": "{{#label}}: debe ser como mínimo de {{limit}} bytes",
+ "binary.max": "{{#label}}: debe ser como máximo de {{limit}} bytes",
+ "binary.length": "{{#label}}: debe tener exactamente {{limit}} bytes",
+ "date.base": "{{#label}}: debe ser una cantidad de milisegundos o una fecha en cadena de texto válida",
+ "date.min": "{{#label}}: debe ser mayor o igual a \"{{limit}}\"",
+ "date.max": "{{#label}}: debe ser menor o igual que \"{{limit}}\"",
+ "date.isoDate": "{{#label}}: debe ser una fecha en formato ISO 8601",
+ "date.ref": "referencia a \"{{ref}}\", que no es una fecha válida",
+ "function.base": "{{#label}}: debe ser una función",
+ "object.base": "{{#label}}: debe ser un objeto",
+ "object.child": "hijo \"{{!key}}\" falló porque {{reason}}",
+ "object.min": "{{#label}}: debe tener como mínimo {{limit}} hijo",
+ "object.max": "{{#label}}: debe tener menos o a lo sumo {{limit}} hijo",
+ "object.length": "{{#label}}: debe tener máximo {{limit}} hijo/s",
+ "object.allowUnknown": "no está permitido",
+ "object.with": "peer faltante: \"{{peer}}\"",
+ "object.without": "conflicto con peer prohibido: \"{{peer}}\"",
+ "object.missing": "{{#label}}: debe contener al menos uno de: {{peers}}",
+ "object.xor": "{{#label}}: contiene un conflicto con alguno de: {{peers}}",
+ "object.or": "{{#label}}: debe contener al menos uno de: {{peers}}",
+ "object.and": "contiene {{present}} sin el requerido: {{missing}}",
+ "object.nand": "!!\"{{main}}\" no debe existir simultáneamente con {{peers}}",
+ "object.assert": "!!\"{{ref}}\" falló validacion porque \"{{ref}}\" falló a {{message}}",
+ "object.rename.multiple": "{{#label}}: no se puede renombrar el hijo \"{{from}}\" porque múltiples re-nombramientos estan deshabilitados y otra clave fue renombrada a \"{{to}}\"",
+ "object.rename.override": "{{#label}}: no se puede renombrar el hijo \"{{from}}\" porque la sobre escritura esta deshabilitada y el target \"{{to}}\" existe",
+ "object.type": "{{#label}}: debe ser una instancia de \"{{type}}\"",
+ "number.base": "{{#label}}: debe ser un número",
+ "number.min": "{{#label}}: debe ser mayor o igual que {{limit}}",
+ "number.max": "{{#label}}: debe ser menor o igual que {{limit}}",
+ "number.less": "{{#label}}: debe ser menor a {{limit}}",
+ "number.greater": "{{#label}}: debe ser mayor a {{limit}}",
+ "number.float": "{{#label}}: debe ser un numero flotante",
+ "number.integer": "{{#label}}: debe ser un número entero",
+ "number.negative": "{{#label}}: debe ser un número negativo",
+ "number.positive": "{{#label}}: debe ser un número positivo",
+ "number.precision": "{{#label}}: no debe tener mas de {{limit}} decimales",
+ "number.ref": "{{#label}}: referencia a \"{{ref}}\" que no es un número",
+ "number.multiple": "{{#label}}: debe ser un múltiplo de {{multiple}}",
+ "string.base": "{{#label}}: debe ser una cadena de texto",
+ "string.min": "{{#label}}: debe ser mínimo de {{limit}} caracteres de largo",
+ "string.max": "{{#label}}: debe ser de máximo {{limit}} caracteres de largo",
+ "string.length": "{{#label}}: debe ser exactamente de {{limit}} caracteres de largo",
+ "string.alphanum": "{{#label}}: debe contener solo letras y números",
+ "string.token": "{{#label}}: debe contener solo letras, números y guines bajos",
+ "string.regex.base": "{{#label}}: el valor \"{{!value}}\" no coincide con el pattern requerido: {{pattern}}",
+ "string.regex.name": "{{#label}}: el valor \"{{!value}}\" no coincide con el nombre de pattern {{name}}",
+ "string.email": "{{#label}}: debe ser un email válido",
+ "string.uri": "{{#label}}: debe sre una uri válida",
+ "string.uriCustomScheme": "{{#label}}: debe ser una uri válida con el esquema concidiente con el patrón {{scheme}}",
+ "string.isoDate": "{{#label}}: debe ser una fecha en formato ISO 8601 válida",
+ "string.guid": "{{#label}}: debe ser un GUID valido",
+ "string.hex": "{{#label}}: debe contener solo caracteres hexadecimales",
+ "string.hostname": "{{#label}}: deber ser un hostname válido",
+ "string.lowercase": "{{#label}}: solo debe contener minúsculas",
+ "string.uppercase": "{{#label}}: solo debe contener mayúsculas",
+ "string.trim": "{{#label}}: no debe tener espacios en blanco delante o atrás",
+ "string.creditCard": "{{#label}}: debe ser una tarjeta de crédito",
+ "string.ref": "Referencia \"{{ref}}\" que no es un número",
+ "string.ip": "{{#label}}: debe ser una dirección ip válida con un CDIR {{cidr}}",
+ "string.ipVersion": "{{#label}}: debe ser una dirección ip válida de una de las siguientes versiones {{version}} con un CDIR {{cidr}}",
+ "object.unknown": "{{#label}}: es un campo no es permitido",
+ "luxon.lt": "{{#label}}: must be before {{#date}}",
+ "luxon.gt": "{{#label}}: must be after {{#date}}",
+ "luxon.lte": "{{#label}}: must be same as or before {{#date}}",
+ "luxon.gte": "{{#label}}: must be same as or after {{#date}}"
+}
diff --git a/client/src/ui/accordion.tsx b/client/src/ui/accordion.tsx
new file mode 100644
index 0000000..e6a723d
--- /dev/null
+++ b/client/src/ui/accordion.tsx
@@ -0,0 +1,56 @@
+import * as React from "react"
+import * as AccordionPrimitive from "@radix-ui/react-accordion"
+import { ChevronDown } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Accordion = AccordionPrimitive.Root
+
+const AccordionItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AccordionItem.displayName = "AccordionItem"
+
+const AccordionTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ svg]:rotate-180",
+ className
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+))
+AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
+
+const AccordionContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ {children}
+
+))
+
+AccordionContent.displayName = AccordionPrimitive.Content.displayName
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
diff --git a/client/src/ui/alert-dialog.tsx b/client/src/ui/alert-dialog.tsx
new file mode 100644
index 0000000..32cf6f8
--- /dev/null
+++ b/client/src/ui/alert-dialog.tsx
@@ -0,0 +1,139 @@
+import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+import { buttonVariants } from "./button";
+
+const AlertDialog = AlertDialogPrimitive.Root;
+
+const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
+
+const AlertDialogPortal = AlertDialogPrimitive.Portal;
+
+const AlertDialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
+
+const AlertDialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+));
+AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
+
+const AlertDialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+AlertDialogHeader.displayName = "AlertDialogHeader";
+
+const AlertDialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+AlertDialogFooter.displayName = "AlertDialogFooter";
+
+const AlertDialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
+
+const AlertDialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogDescription.displayName =
+ AlertDialogPrimitive.Description.displayName;
+
+const AlertDialogAction = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
+
+const AlertDialogCancel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
+
+export {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogOverlay,
+ AlertDialogPortal,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+};
diff --git a/client/src/ui/alert.tsx b/client/src/ui/alert.tsx
new file mode 100644
index 0000000..41fa7e0
--- /dev/null
+++ b/client/src/ui/alert.tsx
@@ -0,0 +1,59 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const alertVariants = cva(
+ "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
+ {
+ variants: {
+ variant: {
+ default: "bg-background text-foreground",
+ destructive:
+ "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+const Alert = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes & VariantProps
+>(({ className, variant, ...props }, ref) => (
+
+))
+Alert.displayName = "Alert"
+
+const AlertTitle = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+AlertTitle.displayName = "AlertTitle"
+
+const AlertDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+AlertDescription.displayName = "AlertDescription"
+
+export { Alert, AlertTitle, AlertDescription }
diff --git a/client/src/ui/aspect-ratio.tsx b/client/src/ui/aspect-ratio.tsx
new file mode 100644
index 0000000..c4abbf3
--- /dev/null
+++ b/client/src/ui/aspect-ratio.tsx
@@ -0,0 +1,5 @@
+import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
+
+const AspectRatio = AspectRatioPrimitive.Root
+
+export { AspectRatio }
diff --git a/client/src/ui/autosize-textarea.tsx b/client/src/ui/autosize-textarea.tsx
new file mode 100644
index 0000000..a2874aa
--- /dev/null
+++ b/client/src/ui/autosize-textarea.tsx
@@ -0,0 +1,73 @@
+"use client";
+import { cn } from "@/lib/utils";
+import * as React from "react";
+import { useImperativeHandle } from "react";
+import { useAutosizeTextArea } from "./use-autosize-textarea";
+
+export type AutosizeTextAreaRef = {
+ textArea: HTMLTextAreaElement;
+ maxHeight: number;
+ minHeight: number;
+};
+
+type AutosizeTextAreaProps = {
+ maxHeight?: number;
+ minHeight?: number;
+} & React.TextareaHTMLAttributes;
+
+export const AutosizeTextarea = React.forwardRef<
+ AutosizeTextAreaRef,
+ AutosizeTextAreaProps
+>(
+ (
+ {
+ maxHeight = Number.MAX_SAFE_INTEGER,
+ minHeight = 52,
+ className,
+ onChange,
+ value,
+ ...props
+ }: AutosizeTextAreaProps,
+ ref: React.Ref,
+ ) => {
+ const textAreaRef = React.useRef(null);
+ const [triggerAutoSize, setTriggerAutoSize] = React.useState("");
+
+ useAutosizeTextArea({
+ textAreaRef: textAreaRef.current,
+ triggerAutoSize: triggerAutoSize,
+ maxHeight,
+ minHeight,
+ });
+
+ useImperativeHandle(ref, () => ({
+ textArea: textAreaRef.current as HTMLTextAreaElement,
+ focus: () => textAreaRef.current?.focus(),
+ maxHeight,
+ minHeight,
+ }));
+
+ React.useEffect(() => {
+ if (value || props?.defaultValue) {
+ setTriggerAutoSize(value as string);
+ }
+ }, [value || props?.defaultValue]);
+
+ return (
+