mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 10:42:57 +00:00
dev day #65
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { api } from '../services/api';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface UseApiMutationResult<T, P = any> {
|
||||
mutate: (data: P) => Promise<T | null>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
success: boolean;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for API mutations (POST, PUT, PATCH, DELETE)
|
||||
* Handles loading states, errors, and success messages
|
||||
*
|
||||
* @example
|
||||
* const { mutate, loading, error } = useApiMutation<Article>('post', '/articles');
|
||||
* await mutate({ title: 'New Article' });
|
||||
*/
|
||||
export function useApiMutation<T = any, P = any>(
|
||||
method: 'post' | 'put' | 'patch' | 'delete',
|
||||
endpoint: string | ((data: P) => string)
|
||||
): UseApiMutationResult<T, P> {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const mutate = useCallback(
|
||||
async (data: P): Promise<T | null> => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
|
||||
const url = typeof endpoint === 'function' ? endpoint(data) : endpoint;
|
||||
const response = await api[method]<ApiResponse<T>>(url, data);
|
||||
|
||||
if (response.data.success !== false) {
|
||||
setSuccess(true);
|
||||
return response.data.data || (response.data as any);
|
||||
} else {
|
||||
setError(response.data.error || response.data.message || 'Operation failed');
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
const axiosError = err as AxiosError<ApiResponse>;
|
||||
const errorMsg =
|
||||
axiosError.response?.data?.error ||
|
||||
axiosError.response?.data?.message ||
|
||||
axiosError.message ||
|
||||
'An error occurred';
|
||||
setError(errorMsg);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[method, endpoint]
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
mutate,
|
||||
loading,
|
||||
error,
|
||||
success,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience hooks for specific HTTP methods
|
||||
*/
|
||||
export const useApiPost = <T = any, P = any>(endpoint: string | ((data: P) => string)) =>
|
||||
useApiMutation<T, P>('post', endpoint);
|
||||
|
||||
export const useApiPut = <T = any, P = any>(endpoint: string | ((data: P) => string)) =>
|
||||
useApiMutation<T, P>('put', endpoint);
|
||||
|
||||
export const useApiPatch = <T = any, P = any>(endpoint: string | ((data: P) => string)) =>
|
||||
useApiMutation<T, P>('patch', endpoint);
|
||||
|
||||
export const useApiDelete = <T = any, P = any>(endpoint: string | ((data: P) => string)) =>
|
||||
useApiMutation<T, P>('delete', endpoint);
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
|
||||
export interface UseBatchSelectionResult<T = any> {
|
||||
selectedIds: Set<number | string>;
|
||||
selectedItems: T[];
|
||||
isSelected: (id: number | string) => boolean;
|
||||
isAllSelected: boolean;
|
||||
isSomeSelected: boolean;
|
||||
toggle: (id: number | string) => void;
|
||||
select: (id: number | string) => void;
|
||||
deselect: (id: number | string) => void;
|
||||
selectAll: () => void;
|
||||
deselectAll: () => void;
|
||||
toggleAll: () => void;
|
||||
setItems: (items: T[]) => void;
|
||||
getSelectedIds: () => (number | string)[];
|
||||
getSelectedItems: () => T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing batch selection in tables/lists
|
||||
* Handles select all, toggle, and individual selections
|
||||
*
|
||||
* @example
|
||||
* const selection = useBatchSelection(articles, 'id');
|
||||
* <Checkbox checked={selection.isSelected(article.id)} onChange={() => selection.toggle(article.id)} />
|
||||
* <button disabled={selection.selectedIds.size === 0} onClick={handleBatchDelete}>
|
||||
* Delete Selected ({selection.selectedIds.size})
|
||||
* </button>
|
||||
*/
|
||||
export function useBatchSelection<T = any>(
|
||||
items: T[] = [],
|
||||
idField: keyof T = 'id' as keyof T
|
||||
): UseBatchSelectionResult<T> {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number | string>>(new Set());
|
||||
const [currentItems, setCurrentItems] = useState<T[]>(items);
|
||||
|
||||
const setItems = useCallback((newItems: T[]) => {
|
||||
setCurrentItems(newItems);
|
||||
// Clear selection when items change
|
||||
setSelectedIds(new Set());
|
||||
}, []);
|
||||
|
||||
const isSelected = useCallback(
|
||||
(id: number | string) => selectedIds.has(id),
|
||||
[selectedIds]
|
||||
);
|
||||
|
||||
const toggle = useCallback((id: number | string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const select = useCallback((id: number | string) => {
|
||||
setSelectedIds((prev) => new Set(prev).add(id));
|
||||
}, []);
|
||||
|
||||
const deselect = useCallback((id: number | string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const selectAll = useCallback(() => {
|
||||
const allIds = currentItems.map((item) => item[idField] as number | string);
|
||||
setSelectedIds(new Set(allIds));
|
||||
}, [currentItems, idField]);
|
||||
|
||||
const deselectAll = useCallback(() => {
|
||||
setSelectedIds(new Set());
|
||||
}, []);
|
||||
|
||||
const toggleAll = useCallback(() => {
|
||||
if (selectedIds.size === currentItems.length && currentItems.length > 0) {
|
||||
deselectAll();
|
||||
} else {
|
||||
selectAll();
|
||||
}
|
||||
}, [selectedIds.size, currentItems.length, selectAll, deselectAll]);
|
||||
|
||||
const selectedItems = useMemo(() => {
|
||||
return currentItems.filter((item) => selectedIds.has(item[idField] as number | string));
|
||||
}, [currentItems, selectedIds, idField]);
|
||||
|
||||
const isAllSelected = useMemo(() => {
|
||||
return currentItems.length > 0 && selectedIds.size === currentItems.length;
|
||||
}, [currentItems.length, selectedIds.size]);
|
||||
|
||||
const isSomeSelected = useMemo(() => {
|
||||
return selectedIds.size > 0 && selectedIds.size < currentItems.length;
|
||||
}, [selectedIds.size, currentItems.length]);
|
||||
|
||||
const getSelectedIds = useCallback(() => {
|
||||
return Array.from(selectedIds);
|
||||
}, [selectedIds]);
|
||||
|
||||
const getSelectedItems = useCallback(() => {
|
||||
return selectedItems;
|
||||
}, [selectedItems]);
|
||||
|
||||
return {
|
||||
selectedIds,
|
||||
selectedItems,
|
||||
isSelected,
|
||||
isAllSelected,
|
||||
isSomeSelected,
|
||||
toggle,
|
||||
select,
|
||||
deselect,
|
||||
selectAll,
|
||||
deselectAll,
|
||||
toggleAll,
|
||||
setItems,
|
||||
getSelectedIds,
|
||||
getSelectedItems,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export interface ValidationRule {
|
||||
required?: boolean;
|
||||
min?: number;
|
||||
max?: number;
|
||||
pattern?: RegExp;
|
||||
email?: boolean;
|
||||
url?: boolean;
|
||||
custom?: (value: any) => string | null;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface ValidationRules {
|
||||
[field: string]: ValidationRule | ValidationRule[];
|
||||
}
|
||||
|
||||
export interface ValidationErrors {
|
||||
[field: string]: string;
|
||||
}
|
||||
|
||||
export interface UseFormValidationResult<T> {
|
||||
values: T;
|
||||
errors: ValidationErrors;
|
||||
touched: { [K in keyof T]?: boolean };
|
||||
isValid: boolean;
|
||||
isSubmitting: boolean;
|
||||
setValue: (field: keyof T, value: any) => void;
|
||||
setValues: (values: Partial<T>) => void;
|
||||
setError: (field: keyof T, error: string) => void;
|
||||
setErrors: (errors: ValidationErrors) => void;
|
||||
clearError: (field: keyof T) => void;
|
||||
clearErrors: () => void;
|
||||
touch: (field: keyof T) => void;
|
||||
validate: () => boolean;
|
||||
validateField: (field: keyof T) => boolean;
|
||||
handleChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => void;
|
||||
handleBlur: (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => void;
|
||||
handleSubmit: (onSubmit: (values: T) => void | Promise<void>) => (e: React.FormEvent) => Promise<void>;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for form validation with built-in rules
|
||||
* Supports required, min/max length, patterns, email, URL, and custom validators
|
||||
*
|
||||
* @example
|
||||
* const { values, errors, handleChange, handleSubmit } = useFormValidation({
|
||||
* title: '',
|
||||
* email: ''
|
||||
* }, {
|
||||
* title: { required: true, min: 3, max: 100 },
|
||||
* email: { required: true, email: true }
|
||||
* });
|
||||
*/
|
||||
export function useFormValidation<T extends Record<string, any>>(
|
||||
initialValues: T,
|
||||
rules: ValidationRules = {}
|
||||
): UseFormValidationResult<T> {
|
||||
const [values, setValuesState] = useState<T>(initialValues);
|
||||
const [errors, setErrorsState] = useState<ValidationErrors>({});
|
||||
const [touched, setTouched] = useState<{ [K in keyof T]?: boolean }>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const validateValue = useCallback(
|
||||
(field: keyof T, value: any): string | null => {
|
||||
const fieldRules = rules[field as string];
|
||||
if (!fieldRules) return null;
|
||||
|
||||
const rulesArray = Array.isArray(fieldRules) ? fieldRules : [fieldRules];
|
||||
|
||||
for (const rule of rulesArray) {
|
||||
// Required
|
||||
if (rule.required && (!value || (typeof value === 'string' && !value.trim()))) {
|
||||
return rule.message || `${String(field)} is required`;
|
||||
}
|
||||
|
||||
// Skip other validations if empty and not required
|
||||
if (!value || (typeof value === 'string' && !value.trim())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Min length
|
||||
if (rule.min !== undefined && typeof value === 'string' && value.length < rule.min) {
|
||||
return rule.message || `${String(field)} must be at least ${rule.min} characters`;
|
||||
}
|
||||
|
||||
// Max length
|
||||
if (rule.max !== undefined && typeof value === 'string' && value.length > rule.max) {
|
||||
return rule.message || `${String(field)} must be at most ${rule.max} characters`;
|
||||
}
|
||||
|
||||
// Pattern
|
||||
if (rule.pattern && typeof value === 'string' && !rule.pattern.test(value)) {
|
||||
return rule.message || `${String(field)} format is invalid`;
|
||||
}
|
||||
|
||||
// Email
|
||||
if (rule.email && typeof value === 'string') {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(value)) {
|
||||
return rule.message || `${String(field)} must be a valid email`;
|
||||
}
|
||||
}
|
||||
|
||||
// URL
|
||||
if (rule.url && typeof value === 'string') {
|
||||
try {
|
||||
new URL(value);
|
||||
} catch {
|
||||
return rule.message || `${String(field)} must be a valid URL`;
|
||||
}
|
||||
}
|
||||
|
||||
// Custom validator
|
||||
if (rule.custom) {
|
||||
const customError = rule.custom(value);
|
||||
if (customError) return customError;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[rules]
|
||||
);
|
||||
|
||||
const validateField = useCallback(
|
||||
(field: keyof T): boolean => {
|
||||
const error = validateValue(field, values[field]);
|
||||
if (error) {
|
||||
setErrorsState((prev) => ({ ...prev, [field]: error }));
|
||||
return false;
|
||||
} else {
|
||||
setErrorsState((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[field as string];
|
||||
return newErrors;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
},
|
||||
[values, validateValue]
|
||||
);
|
||||
|
||||
const validate = useCallback((): boolean => {
|
||||
const newErrors: ValidationErrors = {};
|
||||
let isValid = true;
|
||||
|
||||
Object.keys(rules).forEach((field) => {
|
||||
const error = validateValue(field as keyof T, values[field as keyof T]);
|
||||
if (error) {
|
||||
newErrors[field] = error;
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
setErrorsState(newErrors);
|
||||
return isValid;
|
||||
}, [rules, values, validateValue]);
|
||||
|
||||
const setValue = useCallback((field: keyof T, value: any) => {
|
||||
setValuesState((prev) => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
const setValues = useCallback((newValues: Partial<T>) => {
|
||||
setValuesState((prev) => ({ ...prev, ...newValues }));
|
||||
}, []);
|
||||
|
||||
const setError = useCallback((field: keyof T, error: string) => {
|
||||
setErrorsState((prev) => ({ ...prev, [field]: error }));
|
||||
}, []);
|
||||
|
||||
const setErrors = useCallback((newErrors: ValidationErrors) => {
|
||||
setErrorsState(newErrors);
|
||||
}, []);
|
||||
|
||||
const clearError = useCallback((field: keyof T) => {
|
||||
setErrorsState((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[field as string];
|
||||
return newErrors;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearErrors = useCallback(() => {
|
||||
setErrorsState({});
|
||||
}, []);
|
||||
|
||||
const touch = useCallback((field: keyof T) => {
|
||||
setTouched((prev) => ({ ...prev, [field]: true }));
|
||||
}, []);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
const { name, value, type } = e.target;
|
||||
const fieldValue = type === 'checkbox' ? (e.target as HTMLInputElement).checked : value;
|
||||
setValue(name as keyof T, fieldValue);
|
||||
|
||||
// Clear error on change if field was touched
|
||||
if (touched[name as keyof T]) {
|
||||
clearError(name as keyof T);
|
||||
}
|
||||
},
|
||||
[setValue, clearError, touched]
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(
|
||||
(e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
const { name } = e.target;
|
||||
touch(name as keyof T);
|
||||
validateField(name as keyof T);
|
||||
},
|
||||
[touch, validateField]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(onSubmit: (values: T) => void | Promise<void>) => {
|
||||
return async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
// Mark all fields as touched
|
||||
const allTouched = Object.keys(rules).reduce(
|
||||
(acc, key) => ({ ...acc, [key]: true }),
|
||||
{}
|
||||
);
|
||||
setTouched(allTouched);
|
||||
|
||||
if (validate()) {
|
||||
try {
|
||||
await onSubmit(values);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
} else {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
},
|
||||
[validate, values, rules]
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setValuesState(initialValues);
|
||||
setErrorsState({});
|
||||
setTouched({});
|
||||
setIsSubmitting(false);
|
||||
}, [initialValues]);
|
||||
|
||||
const isValid = Object.keys(errors).length === 0;
|
||||
|
||||
return {
|
||||
values,
|
||||
errors,
|
||||
touched,
|
||||
isValid,
|
||||
isSubmitting,
|
||||
setValue,
|
||||
setValues,
|
||||
setError,
|
||||
setErrors,
|
||||
clearError,
|
||||
clearErrors,
|
||||
touch,
|
||||
validate,
|
||||
validateField,
|
||||
handleChange,
|
||||
handleBlur,
|
||||
handleSubmit,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { api } from '../services/api';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
export interface PaginationMeta {
|
||||
page: number;
|
||||
page_size: number;
|
||||
total: number;
|
||||
total_pages: number;
|
||||
has_next: boolean;
|
||||
has_prev: boolean;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
data: T[];
|
||||
meta: PaginationMeta;
|
||||
}
|
||||
|
||||
export interface QueryParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
search?: string;
|
||||
sort?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface UsePaginatedDataResult<T> {
|
||||
data: T[];
|
||||
meta: PaginationMeta | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => void;
|
||||
setPage: (page: number) => void;
|
||||
setPageSize: (size: number) => void;
|
||||
setSearch: (search: string) => void;
|
||||
setSort: (field: string, order: 'asc' | 'desc') => void;
|
||||
setFilters: (filters: Record<string, any>) => void;
|
||||
updateQueryParams: (params: Partial<QueryParams>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for fetching paginated data with search, sort, and filters
|
||||
* Automatically handles pagination, loading states, and errors
|
||||
*
|
||||
* @example
|
||||
* const { data, meta, loading, setSearch, setPage } = usePaginatedData<Article>('/articles');
|
||||
*/
|
||||
export function usePaginatedData<T = any>(
|
||||
endpoint: string,
|
||||
initialParams: QueryParams = {}
|
||||
): UsePaginatedDataResult<T> {
|
||||
const [data, setData] = useState<T[]>([]);
|
||||
const [meta, setMeta] = useState<PaginationMeta | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [queryParams, setQueryParams] = useState<QueryParams>({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
...initialParams,
|
||||
});
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Build query string
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(queryParams).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
params.append(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
const response = await api.get<PaginatedResponse<T>>(
|
||||
`${endpoint}?${params.toString()}`
|
||||
);
|
||||
|
||||
if (response.data.success) {
|
||||
setData(response.data.data || []);
|
||||
setMeta(response.data.meta || null);
|
||||
} else {
|
||||
setError(response.data.message || 'Failed to fetch data');
|
||||
}
|
||||
} catch (err) {
|
||||
const axiosError = err as AxiosError<{ error: string }>;
|
||||
setError(
|
||||
axiosError.response?.data?.error ||
|
||||
axiosError.message ||
|
||||
'An error occurred'
|
||||
);
|
||||
setData([]);
|
||||
setMeta(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [endpoint, queryParams]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const updateQueryParams = useCallback((params: Partial<QueryParams>) => {
|
||||
setQueryParams((prev) => ({ ...prev, ...params }));
|
||||
}, []);
|
||||
|
||||
const setPage = useCallback((page: number) => {
|
||||
updateQueryParams({ page });
|
||||
}, [updateQueryParams]);
|
||||
|
||||
const setPageSize = useCallback((page_size: number) => {
|
||||
updateQueryParams({ page_size, page: 1 }); // Reset to first page
|
||||
}, [updateQueryParams]);
|
||||
|
||||
const setSearch = useCallback((search: string) => {
|
||||
updateQueryParams({ search, page: 1 }); // Reset to first page
|
||||
}, [updateQueryParams]);
|
||||
|
||||
const setSort = useCallback((field: string, order: 'asc' | 'desc') => {
|
||||
updateQueryParams({ sort: `${field}:${order}` });
|
||||
}, [updateQueryParams]);
|
||||
|
||||
const setFilters = useCallback((filters: Record<string, any>) => {
|
||||
updateQueryParams({ ...filters, page: 1 }); // Reset to first page
|
||||
}, [updateQueryParams]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
return {
|
||||
data,
|
||||
meta,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
setPage,
|
||||
setPageSize,
|
||||
setSearch,
|
||||
setSort,
|
||||
setFilters,
|
||||
updateQueryParams,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
|
||||
export interface QueryFilters {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface SortConfig {
|
||||
field: string;
|
||||
order: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface UseQueryBuilderResult {
|
||||
filters: QueryFilters;
|
||||
search: string;
|
||||
sort: SortConfig | null;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
queryString: string;
|
||||
queryParams: URLSearchParams;
|
||||
setFilter: (key: string, value: any) => void;
|
||||
removeFilter: (key: string) => void;
|
||||
clearFilters: () => void;
|
||||
setSearch: (search: string) => void;
|
||||
setSort: (field: string, order: 'asc' | 'desc') => void;
|
||||
toggleSort: (field: string) => void;
|
||||
clearSort: () => void;
|
||||
setPage: (page: number) => void;
|
||||
setPageSize: (size: number) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for building query strings with filters, search, sort, and pagination
|
||||
* Works seamlessly with the backend QueryParser
|
||||
*
|
||||
* @example
|
||||
* const { queryString, setFilter, setSearch, setSort } = useQueryBuilder();
|
||||
* setFilter('published', true);
|
||||
* setSearch('football');
|
||||
* setSort('created_at', 'desc');
|
||||
* // queryString: "published=true&search=football&sort=created_at:desc&page=1&page_size=20"
|
||||
*/
|
||||
export function useQueryBuilder(initialPageSize: number = 20): UseQueryBuilderResult {
|
||||
const [filters, setFilters] = useState<QueryFilters>({});
|
||||
const [search, setSearchState] = useState('');
|
||||
const [sort, setSortState] = useState<SortConfig | null>(null);
|
||||
const [page, setPageState] = useState(1);
|
||||
const [pageSize, setPageSizeState] = useState(initialPageSize);
|
||||
|
||||
const setFilter = useCallback((key: string, value: any) => {
|
||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||
setPageState(1); // Reset to first page when filter changes
|
||||
}, []);
|
||||
|
||||
const removeFilter = useCallback((key: string) => {
|
||||
setFilters((prev) => {
|
||||
const newFilters = { ...prev };
|
||||
delete newFilters[key];
|
||||
return newFilters;
|
||||
});
|
||||
setPageState(1);
|
||||
}, []);
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setFilters({});
|
||||
setPageState(1);
|
||||
}, []);
|
||||
|
||||
const setSearch = useCallback((searchTerm: string) => {
|
||||
setSearchState(searchTerm);
|
||||
setPageState(1); // Reset to first page when search changes
|
||||
}, []);
|
||||
|
||||
const setSort = useCallback((field: string, order: 'asc' | 'desc') => {
|
||||
setSortState({ field, order });
|
||||
}, []);
|
||||
|
||||
const toggleSort = useCallback((field: string) => {
|
||||
setSortState((prev) => {
|
||||
if (!prev || prev.field !== field) {
|
||||
return { field, order: 'asc' };
|
||||
}
|
||||
if (prev.order === 'asc') {
|
||||
return { field, order: 'desc' };
|
||||
}
|
||||
return null; // Clear sort
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearSort = useCallback(() => {
|
||||
setSortState(null);
|
||||
}, []);
|
||||
|
||||
const setPage = useCallback((newPage: number) => {
|
||||
setPageState(newPage);
|
||||
}, []);
|
||||
|
||||
const setPageSize = useCallback((size: number) => {
|
||||
setPageSizeState(size);
|
||||
setPageState(1); // Reset to first page
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setFilters({});
|
||||
setSearchState('');
|
||||
setSortState(null);
|
||||
setPageState(1);
|
||||
setPageSizeState(initialPageSize);
|
||||
}, [initialPageSize]);
|
||||
|
||||
const queryParams = useMemo(() => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
// Add filters
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
params.append(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
// Add search
|
||||
if (search) {
|
||||
params.append('search', search);
|
||||
}
|
||||
|
||||
// Add sort
|
||||
if (sort) {
|
||||
params.append('sort', `${sort.field}:${sort.order}`);
|
||||
}
|
||||
|
||||
// Add pagination
|
||||
params.append('page', String(page));
|
||||
params.append('page_size', String(pageSize));
|
||||
|
||||
return params;
|
||||
}, [filters, search, sort, page, pageSize]);
|
||||
|
||||
const queryString = useMemo(() => {
|
||||
return queryParams.toString();
|
||||
}, [queryParams]);
|
||||
|
||||
return {
|
||||
filters,
|
||||
search,
|
||||
sort,
|
||||
page,
|
||||
pageSize,
|
||||
queryString,
|
||||
queryParams,
|
||||
setFilter,
|
||||
removeFilter,
|
||||
clearFilters,
|
||||
setSearch,
|
||||
setSort,
|
||||
toggleSort,
|
||||
clearSort,
|
||||
setPage,
|
||||
setPageSize,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'warning' | 'info';
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
type: ToastType;
|
||||
message: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export interface UseToastResult {
|
||||
toasts: Toast[];
|
||||
success: (message: string, duration?: number) => void;
|
||||
error: (message: string, duration?: number) => void;
|
||||
warning: (message: string, duration?: number) => void;
|
||||
info: (message: string, duration?: number) => void;
|
||||
dismiss: (id: string) => void;
|
||||
dismissAll: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for displaying toast notifications
|
||||
* Automatically dismisses toasts after specified duration
|
||||
*
|
||||
* @example
|
||||
* const toast = useToast();
|
||||
* toast.success('Article created successfully');
|
||||
* toast.error('Failed to save changes');
|
||||
*/
|
||||
export function useToast(): UseToastResult {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const timeoutsRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
|
||||
|
||||
const addToast = useCallback((type: ToastType, message: string, duration: number = 5000) => {
|
||||
const id = `toast-${Date.now()}-${Math.random()}`;
|
||||
const toast: Toast = { id, type, message, duration };
|
||||
|
||||
setToasts((prev) => [...prev, toast]);
|
||||
|
||||
// Auto-dismiss after duration
|
||||
if (duration > 0) {
|
||||
const timeout = setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
timeoutsRef.current.delete(id);
|
||||
}, duration);
|
||||
timeoutsRef.current.set(id, timeout);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const dismiss = useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
const timeout = timeoutsRef.current.get(id);
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
timeoutsRef.current.delete(id);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const dismissAll = useCallback(() => {
|
||||
setToasts([]);
|
||||
timeoutsRef.current.forEach((timeout) => clearTimeout(timeout));
|
||||
timeoutsRef.current.clear();
|
||||
}, []);
|
||||
|
||||
const success = useCallback(
|
||||
(message: string, duration?: number) => addToast('success', message, duration),
|
||||
[addToast]
|
||||
);
|
||||
|
||||
const error = useCallback(
|
||||
(message: string, duration?: number) => addToast('error', message, duration),
|
||||
[addToast]
|
||||
);
|
||||
|
||||
const warning = useCallback(
|
||||
(message: string, duration?: number) => addToast('warning', message, duration),
|
||||
[addToast]
|
||||
);
|
||||
|
||||
const info = useCallback(
|
||||
(message: string, duration?: number) => addToast('info', message, duration),
|
||||
[addToast]
|
||||
);
|
||||
|
||||
return {
|
||||
toasts,
|
||||
success,
|
||||
error,
|
||||
warning,
|
||||
info,
|
||||
dismiss,
|
||||
dismissAll,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user