This commit is contained in:
Tomas Dvorak
2025-10-19 18:09:28 +02:00
parent abe127fb51
commit 9ccca365b3
40 changed files with 6885 additions and 20 deletions
+96
View File
@@ -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);
+126
View File
@@ -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,
};
}
+272
View File
@@ -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,
};
}
+146
View File
@@ -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,
};
}
+161
View File
@@ -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,
};
}
+95
View File
@@ -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,
};
}