UNPKG

pagamio-frontend-commons-lib

Version:

Pagamio library for Frontend reusable components like the form engine and table container

476 lines (475 loc) 20.3 kB
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useToast } from '../../context'; import { getNestedValue } from '../utils'; // Default Spring Boot response mapping export const springBootMapping = { responseType: 'spring-boot', data: 'content', total: 'totalElements', page: 'number', pageSize: 'size', defaultValues: { data: [], total: 0, page: 0, pageSize: 10, }, }; // array response query parameter configuration export const arrayResponseMapping = { responseType: 'default', // Indicates a non-Spring Boot response data: '', // Path to the data array (empty string means the response itself is the data) total: '', // Path to the total count (not applicable for plain arrays) page: '', // Path to the current page number (not applicable for plain arrays) pageSize: '', // Path to the page size (not applicable for plain arrays) defaultValues: { data: [], // Default data if the response is invalid total: 0, // Default total count page: 0, // Default page number pageSize: 10, // Default page size }, }; // Default query parameter configuration const defaultQueryConfig = { pagination: { transform: (page, size) => ({ page: page.toString(), size: size.toString(), }), }, sorting: { transform: (sortBy, sortDir) => ({ sort: `${sortBy},${sortDir}`, }), }, filtering: { searchParam: 'name', filterFormat: 'flat', }, }; /** * Custom hook for managing table data with pagination, sorting, and filtering. * Supports both client-side and server-side operations. * * @template T - The type of data items in the table. Must extend `BaseEntity`. * * @param {UsePagamioTableProps<T>} props - Configuration options for the hook. * @param {Function} [props.fetchData] - Function to fetch data from the API (required for server-side operations). * @param {T[]} [props.data] - Initial data for client-side operations. * @param {CoreTableColumnDef<T>[]} props.columns - Column definitions for the table. * @param {Record<string, string>} [props.defaultFilters] - Initial filter values. * @param {ResponseMapping} [props.responseMapping] - Configuration for mapping API response structure. * @param {QueryParamConfig} [props.queryParamConfig] - Configuration for query parameter formatting. * @param {Object} [props.pagination] - Pagination configuration. * @param {boolean} [props.pagination.enabled] - Whether pagination is enabled. * @param {'client' | 'server'} [props.pagination.mode] - Pagination mode ('client' or 'server'). * @param {number} [props.pagination.pageSize] - Default page size. * @param {Object} [props.filtering] - Filtering configuration. * @param {boolean} [props.filtering.enabled] - Whether filtering is enabled. * @param {'client' | 'server'} [props.filtering.mode] - Filtering mode ('client' or 'server'). * @param {Object} [props.sorting] - Sorting configuration. * @param {boolean} [props.sorting.enabled] - Whether sorting is enabled. * @param {'client' | 'server'} [props.sorting.mode] - Sorting mode ('client' or 'server'). * * @returns {Object} - Table state and utility functions. * @returns {T[]} data - The processed and paginated data. * @returns {number} totalItems - Total number of items (for server-side pagination). * @returns {boolean} loading - Whether data is being fetched. * @returns {string | null} error - Error message, if any. * @returns {number} currentPage - Current page index. * @returns {number} itemsPerPage - Number of items per page. * @returns {string} searchQuery - Current search query. * @returns {Record<string, string>} appliedFilters - Currently applied filters. * @returns {Record<string, string>} selectedFilters - Currently selected filters. * @returns {SortConfig} sortConfig - Current sorting configuration. * @returns {CoreTableColumnDef<T>[]} columns - Column definitions. * @returns {Function} handleApplyFilters - Function to apply filters. * @returns {Function} handlePageChange - Function to change the current page. * @returns {Function} handlePaginationChange - Function to change pagination settings. * @returns {Function} setSearchQuery - Function to update the search query. * @returns {Function} setCurrentPage - Function to update the current page. * @returns {Function} setItemsPerPage - Function to update the items per page. * @returns {Function} handleFilterChange - Function to update filters. * @returns {Function} handleSort - Function to update sorting. * @returns {Function} handleClearFilters - Function to clear all filters. * @returns {Function} refresh - Function to manually refresh the table data. */ export const usePagamioTable = ({ fetchData, data: clientData, columns, defaultFilters = {}, responseMapping = springBootMapping, queryParamConfig = defaultQueryConfig, pagination = { enabled: true, mode: 'client' }, filtering = { enabled: true, mode: 'client', searchMode: 'server', }, sorting = { enabled: true, mode: 'client' }, }) => { const [data, setData] = useState([]); const [loading, setLoading] = useState(true); const [currentPage, setCurrentPage] = useState(pagination.pageIndex ?? 0); const [itemsPerPage, setItemsPerPage] = useState(pagination.pageSize ?? 10); const [searchQuery, setSearchQuery] = useState(''); const [filters, setFilters] = useState(defaultFilters); const [appliedFilters, setAppliedFilters] = useState(defaultFilters); const [sortConfig, setSortConfig] = useState({}); const [totalItems, setTotalItems] = useState(clientData?.length ?? 0); const [error, setError] = useState(null); const { addToast } = useToast(); const fetchController = useRef(null); const fetchDataRef = useRef(fetchData); const queryConfigRef = useRef(queryParamConfig); const searchMode = useMemo(() => filtering?.searchMode ?? filtering?.mode ?? 'client', [filtering?.searchMode, filtering?.mode]); useEffect(() => { fetchDataRef.current = fetchData; }, [fetchData]); useEffect(() => { queryConfigRef.current = queryParamConfig; }, [queryParamConfig]); const allModesAreClient = useCallback(() => { return (filtering.mode !== 'server' && pagination.mode !== 'server' && sorting.mode !== 'server' && searchMode !== 'server'); }, [filtering.mode, pagination.mode, sorting.mode, searchMode]); const processResponse = useCallback((response) => { const dataPath = responseMapping.data ?? springBootMapping.data; const totalPath = responseMapping.total ?? springBootMapping.total; const pagePath = responseMapping.page ?? springBootMapping.page; const pageSizePath = responseMapping.pageSize ?? springBootMapping.pageSize; const extractedData = dataPath ? getNestedValue(response, dataPath) : response; const extractedTotal = totalPath ? getNestedValue(response, totalPath) : response?.length; const extractedPage = pagePath ? getNestedValue(response, pagePath) : 0; const extractedPageSize = pageSizePath ? getNestedValue(response, pageSizePath) : 10; return { data: extractedData ?? responseMapping.defaultValues?.data ?? [], total: extractedTotal ?? responseMapping.defaultValues?.total ?? 0, page: extractedPage ?? responseMapping.defaultValues?.page ?? 0, pageSize: extractedPageSize ?? responseMapping.defaultValues?.pageSize ?? 10, }; }, [responseMapping]); // Helper function to add pagination parameters const addPaginationParams = (queryParams, config, currentPage, itemsPerPage) => { if (config?.pagination?.transform) { Object.assign(queryParams, config.pagination.transform(currentPage, itemsPerPage)); } else { queryParams[config?.pagination?.pageParam ?? 'page'] = currentPage.toString(); queryParams[config?.pagination?.sizeParam ?? 'size'] = itemsPerPage.toString(); } }; // Helper function to add sorting parameters const addSortingParams = (queryParams, config, sortConfig) => { if (config?.sorting?.transform) { Object.assign(queryParams, config.sorting.transform(sortConfig.sortBy, sortConfig.sortDir ?? 'asc')); } else { queryParams[config?.sorting?.sortByParam ?? 'sortBy'] = sortConfig.sortBy; queryParams[config?.sorting?.sortDirParam ?? 'sortDir'] = sortConfig.sortDir ?? 'asc'; } }; // Helper function to add filtering parameters const addFilteringParams = (queryParams, config, appliedFilters, searchMode) => { const { searchParam, filterFormat, filterPrefix, transform } = config?.filtering ?? {}; if (transform) { Object.assign(queryParams, transform(appliedFilters)); return; } if (appliedFilters.search && searchParam && searchMode === 'server') { queryParams[searchParam] = appliedFilters.search; } const format = filterFormat ?? 'bracket'; const prefix = filterPrefix ?? 'filter'; Object.entries(appliedFilters).forEach(([key, value]) => { if (value && value !== 'all' && key !== 'search') { switch (format) { case 'bracket': queryParams[`filters[${key}]`] = value; break; case 'flat': queryParams[key] = value; break; case 'prefix': queryParams[`${prefix}_${key}`] = value; break; default: queryParams[`filters[${key}]`] = value; } } }); }; // Main function to build query parameters const buildQueryParams = useCallback(() => { const queryParams = {}; const config = queryConfigRef.current; if (pagination.mode === 'server') { addPaginationParams(queryParams, config, currentPage, itemsPerPage); } if (sorting.mode === 'server' && sortConfig.sortBy) { addSortingParams(queryParams, config, sortConfig); } if (filtering.mode === 'server' && Object.keys(appliedFilters).length > 0) { addFilteringParams(queryParams, config, appliedFilters, searchMode); } return queryParams; }, [ currentPage, itemsPerPage, sortConfig, appliedFilters, filtering.mode, pagination.mode, sorting.mode, searchMode, ]); // Helper function to update state with processed data const updateState = useCallback((processed) => { setData(processed.data); setTotalItems(processed.total); if (pagination.mode === 'server') { if (processed.page !== undefined && processed.page !== currentPage) { setCurrentPage(processed.page); } if (processed.pageSize !== undefined && processed.pageSize !== itemsPerPage) { setItemsPerPage(processed.pageSize); } } }, [pagination.mode, currentPage, itemsPerPage]); // Helper function to handle errors const handleError = useCallback((err) => { if (err.name !== 'AbortError') { setError(err instanceof Error ? err.message : 'Unknown error'); addToast({ variant: 'error', title: 'Error', message: err instanceof Error ? err.message : 'Failed to fetch data', }); } }, [addToast]); const fetchTableData = useCallback(async () => { // Early exit if fetchData is not provided and clientData is not available if (!fetchDataRef.current && clientData) { setLoading(false); return; } // If all modes are client and clientData is provided, use it directly if (allModesAreClient() && clientData) { setData(clientData); setTotalItems(clientData.length); setLoading(false); return; } try { setLoading(true); setError(null); // Abort previous request if it exists if (fetchController.current) { fetchController.current.abort(); } fetchController.current = new AbortController(); // Build query parameters const queryParams = buildQueryParams(); // Fetch data from the API if (!fetchDataRef.current) { throw new Error('fetchData function is not defined'); } const response = await fetchDataRef.current(queryParams, fetchController.current.signal); // Only process the response if the request wasn't aborted if (!fetchController.current.signal.aborted) { const processed = processResponse(response); updateState(processed); } } catch (err) { if (err.name !== 'AbortError') { handleError(err); } } finally { // Only set loading to false if the request wasn't aborted if (!fetchController.current?.signal.aborted) { setLoading(false); } } }, [allModesAreClient, buildQueryParams, processResponse, updateState, handleError, clientData]); // Process data for client-side operations const processedData = useMemo(() => { let processed = [...data]; if (searchMode === 'client' && searchQuery) { const query = searchQuery.toLowerCase(); processed = processed.filter((item) => Object.values(item).some((value) => value?.toString().toLowerCase().includes(query))); } if (filtering.mode === 'client' && Object.keys(appliedFilters).length > 0) { processed = processed.filter((item) => Object.entries(appliedFilters).every(([key, value]) => key === 'search' || !value || value === 'all' || item[key]?.toString() === value)); } if (sorting.mode === 'client' && sortConfig.sortBy) { processed.sort((a, b) => { const aValue = a[sortConfig.sortBy]; const bValue = b[sortConfig.sortBy]; const sortMultiplier = sortConfig.sortDir === 'asc' ? 1 : -1; return aValue < bValue ? -sortMultiplier : sortMultiplier; }); } return processed; }, [data, searchQuery, appliedFilters, sortConfig, filtering.mode, sorting.mode, searchMode]); // ✅ Update totalItems reactively based on processed data length in client mode useEffect(() => { if (filtering.mode === 'client' || searchMode === 'client') { setTotalItems(processedData.length); } }, [processedData.length, filtering.mode, searchMode]); const paginatedData = useMemo(() => { if (pagination.mode === 'client' && pagination.enabled) { const start = currentPage * itemsPerPage; return processedData.slice(start, start + itemsPerPage); } return processedData; }, [processedData, currentPage, itemsPerPage, pagination.mode, pagination.enabled]); // Filter change handler const handleFilterChange = useCallback((columnKey, value) => { setFilters((prev) => ({ ...prev, [columnKey]: value })); }, []); // Sort handler const handleSort = useCallback((sortBy, sortDir) => { setSortConfig({ sortBy, sortDir }); }, []); // Clear filters handler const handleClearFilters = useCallback(() => { setSearchQuery(''); setFilters(defaultFilters); setAppliedFilters(defaultFilters); }, [defaultFilters]); const handleApplyFilters = useCallback(() => { const newAppliedFilters = { ...filters, search: searchQuery, }; // Only update if filters have actually changed const filtersChanged = JSON.stringify(newAppliedFilters) !== JSON.stringify(appliedFilters); if (!filtersChanged) { return; } // Use functional update to ensure we're working with the latest state setAppliedFilters((prev) => { // Only update if filters have actually changed if (JSON.stringify(prev) !== JSON.stringify(newAppliedFilters)) { return newAppliedFilters; } return prev; }); // Reset to first page when filters change setCurrentPage(0); }, [searchQuery, filters, appliedFilters]); const handlePageChange = useCallback((newPage) => { setCurrentPage(newPage); }, [currentPage]); const handlePaginationChange = useCallback((newPageIndex, newPageSize) => { setCurrentPage(newPageIndex); setItemsPerPage(newPageSize); }, [currentPage, itemsPerPage]); // Main data fetching effect useEffect(() => { // If all modes are client and clientData is provided, use it directly if (allModesAreClient() && clientData) { setData(clientData); setTotalItems(clientData.length); setLoading(false); return; } // Call the async function fetchTableData().then(() => setLoading(false)); }, [currentPage, itemsPerPage, appliedFilters, sortConfig, filtering.mode, pagination.mode, sorting.mode]); useEffect(() => { return () => { if (fetchController.current) { fetchController.current.abort(); } }; }, []); // Return a memoized value to prevent unnecessary re-renders return useMemo(() => ({ data: paginatedData, totalItems, loading, error, currentPage, itemsPerPage, searchQuery, selectedFilters: filters, appliedFilters, sortConfig, columns, handleApplyFilters, handlePageChange, handlePaginationChange, setSearchQuery, setCurrentPage, setItemsPerPage, setAppliedFilters, handleFilterChange, handleSort, handleClearFilters, refresh: fetchTableData, }), [ paginatedData, totalItems, loading, error, currentPage, itemsPerPage, searchQuery, filters, appliedFilters, sortConfig, columns, setAppliedFilters, handleApplyFilters, handlePageChange, handlePaginationChange, handleFilterChange, handleSort, handleClearFilters, fetchTableData, ]); }; /** * Example usage of the `usePagamioTable` hook. * * @example * const table = usePagamioTable<Product>({ * fetchData: fetchProducts, * columns: columns as unknown as CoreTableColumnDef<Product>[], * defaultFilters: initialFilters, * pagination: { enabled: true, mode: "server" }, * filtering: { enabled: true, mode: "server" }, * queryParamConfig: { * filtering: { searchParam: "name", filterFormat: "flat" }, * }, * }); * * // Render the table * return ( * <CoreTable * columns={table.columns} * data={table.data} * pagination={{ * enabled: true, * pageIndex: table.currentPage, * pageSize: table.itemsPerPage, * itemsPerPage: table.itemsPerPage, * itemsPerPageOptions: [10, 25, 50], * onPageChange: table.handlePageChange, * onPaginationChange: table.handlePaginationChange, * }} * filtering={{ * filters: filters, * appliedFilters: table.selectedFilters, * onTableFilter: table.handleFilterChange, * }} * sorting={{ * sortConfig: table.sortConfig, * onSort: table.handleSort, * }} * search={{ * enabled: true, * searchQuery: table.searchQuery, * onSearch: (e) => table.setSearchQuery(e.target.value), * }} * onClearFilters={table.handleClearFilters} * /> * ); */