Skip to content

Instantly share code, notes, and snippets.

@zbeyens
Created April 16, 2024 19:09
Show Gist options
  • Save zbeyens/9eb6f361fc3c1f1cb09ac3c93e1a6298 to your computer and use it in GitHub Desktop.
Save zbeyens/9eb6f361fc3c1f1cb09ac3c93e1a6298 to your computer and use it in GitHub Desktop.
'use client';
import * as React from 'react';
import type { DataTableFilterField } from '@/components/ui/data-table/data-table.types';
import {
type ColumnDef,
type ColumnFiltersState,
type PaginationState,
type SortingState,
type VisibilityState,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table';
import { useSearchParams } from 'next/navigation';
import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
import { useDebounce } from '@/hooks/useDebounce';
interface UseDataTableProps<TData, TValue> {
/**
* The columns of the table.
*
* @default [ ]
* @type ColumnDef<TData, TValue>[]
*/
columns: ColumnDef<TData, TValue>[];
/**
* The data for the table.
*
* @default [ ]
* @type TData[]
*/
data: TData[];
/**
* Enable notion like column filters. Advanced filters and column filters
* cannot be used at the same time.
*
* @default false
* @type boolean
*/
enableAdvancedFilter?: 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.
*
* @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" }
* ]
* }
* ];
* ```;
*
* @default [ ]
* @type {label: string, value: keyof TData, placeholder?: string, options?: { label: string, value: string, icon?: React.ComponentType<{ className?: string }> }[]}
*/
filterFields?: DataTableFilterField<TData>[];
/**
* The number of pages in the table.
*
* @type number
*/
pageCount: number;
}
export function useDataTable<TData, TValue>({
columns,
data,
enableAdvancedFilter = false,
filterFields = [],
pageCount,
}: UseDataTableProps<TData, TValue>) {
const searchParams = useSearchParams();
const keyMap: Parameters<typeof useQueryStates>[0] = {};
filterFields.forEach(({ value }) => {
keyMap[value] = parseAsString;
});
const [queryState, setQueryState] = useQueryStates({
...keyMap,
page: parseAsInteger.withDefault(1),
per_page: parseAsInteger.withDefault(10),
sort: parseAsString.withDefault('createdAt.desc'),
});
// Search params
const [column, order] = queryState.sort?.split('.') ?? [];
// Memoize computation of searchableColumns and filterableColumns
const { filterableColumns, searchableColumns } = React.useMemo(() => {
return {
filterableColumns: filterFields.filter((field) => field.options),
searchableColumns: filterFields.filter((field) => !field.options),
};
}, [filterFields]);
// Initial column filters
const initialColumnFilters: ColumnFiltersState = React.useMemo(() => {
// eslint-disable-next-line unicorn/no-array-reduce
return Object.keys(queryState).reduce<ColumnFiltersState>(
(filters, key) => {
const value = queryState[key];
const filterableColumn = filterableColumns.find(
(column) => column.value === key
);
const searchableColumn = searchableColumns.find(
(column) => column.value === key
);
if (value?.length && filterableColumn) {
filters.push({
id: key,
value: value.split('.'),
});
} else if (value?.length && searchableColumn) {
filters.push({
id: key,
value: [value],
});
}
return filters;
},
[]
);
}, [queryState, filterableColumns, searchableColumns]);
// Table states
const [rowSelection, setRowSelection] = React.useState({});
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({});
const [columnFilters, setColumnFilters] =
React.useState<ColumnFiltersState>(initialColumnFilters);
// Handle server-side pagination
const [{ pageIndex, pageSize }, setPagination] =
React.useState<PaginationState>({
pageIndex: queryState.page - 1,
pageSize: queryState.per_page,
});
const pagination = React.useMemo(
() => ({
pageIndex,
pageSize,
}),
[pageIndex, pageSize]
);
React.useEffect(() => {
void setQueryState(
{
...queryState,
page: pageIndex + 1,
per_page: pageSize,
},
{
scroll: false,
}
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageIndex, pageSize]);
// Handle server-side sorting
const [sorting, setSorting] = React.useState<SortingState>([
{
desc: order === 'desc',
id: column ?? '',
},
]);
React.useEffect(() => {
void setQueryState({
...queryState,
sort: sorting[0]?.id
? `${sorting[0]?.id}.${sorting[0]?.desc ? 'desc' : 'asc'}`
: null,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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] = React.useState(false);
React.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]: column.value,
});
}
}
// 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.some((column) => column.value === key) &&
!debouncedSearchableColumnFilters.some(
(column) => column.id === key
)) ||
(filterableColumns.some((column) => column.value === key) &&
!filterableColumnFilters.some((column) => column.id === key))
) {
Object.assign(newParamsObject, { [key]: null });
}
}
// After cumulating all the changes, push new params
void setQueryState({
...queryState,
...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 table = useReactTable({
columns,
data,
enableRowSelection: true,
getCoreRowModel: getCoreRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
manualFiltering: true,
manualPagination: true,
manualSorting: true,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
pageCount: pageCount ?? -1,
state: {
columnFilters,
columnVisibility,
pagination,
rowSelection,
sorting,
},
});
return { table };
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment