@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
JavaScript
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}
* />
* );
*/